[AWS Secret Manager] 패스워드 생명주기 관리하는 법.

안녕하세요!
오늘은 패스워드를 암호화 시켜서 관리하고,
주기적으로 패스워드가 Rotation 되도록 구성하는 Secret Manager에 대해서
알아보도록 하겠습니다.

01. Secret Manager란?
어플리케이션, 서비스, IT 관련된 리소스에 액세스하는 필요한 보안 정보(Secret Key)를
안전하게 저장하고 관리하는 AWS 서비스 입니다.
코드 내에 암호를 하드코딩 한다면, 노출이 되고 보안에 취약하여 이를 방지하고자 Secret Manager를 사용합니다.
서버 내에 AWS SDK 라이브러리를 설치하거나, 직접 API를 호출하도록 구성이 가능하지만,
오늘 진행하는 내용은 직접 API를 호출하여 사용하도록 구성하였습니다.
📌 주요 기능
- 중앙 집중식 보안 정보 관리
- 데이터베이스 자격 증명
- API Key
- OAuth Token
- 암호화 키
- 기타 민감한 텍스트 데이터
- 자동 암호화
- AWS KMS(Key Management Service)를 사용한 암호화
- 저장 시 암호화(Encryption at Rest)
- 전송 중 암호화(Encryption in Transit)
- 자동 회전
- 주기적으로 비밀번호 자동 변경
- RDS, Redshift 등과 네이티브 통합 가능.
- 다운타임 없는 회전
- 세밀한 접근 제어
- IAM Policy 기반 권한 관리
- 리소스별, 작업별 권한 분리
- 모든 접근 CloudTrail 로깅
📌 API 호출 시 프로세스
애플리케이션이 Secret Manager API 호출 → Secret Manager가 KMS로 복호화 요청 → 복호화된 평문을 애플리케이션에 반환 → 모든 요청 및 작업들이 CloudTrail로 Logging 저장됨.
📌 Secret Manager 도입 배경
- 어플리케이션 단에서 DB와의 커넥션 파일에 하드코딩된 DB 정보 완전 제거.
- 모든 자격 증명 정보를 암호화 저장
- RDS 비밀번호 자동 회전 (생명 주기 관리)
- 보안 컴플라이언스 요구사항 충족 - ISMS 및 PCI-DSS 참고.
- 기존 애플리케이션 코드 최소 수정
02. 아키텍처 설계
RDS 자격 증명 관리

데이터베이스의 암호 관리 유형이 두 가지가 있습니다.
아래 사진과 같이 '자체 관리'는 말그대로 사용자가 직접 암호를 입력하고 관리하는 방식이고,
Secret Manager에서 관리 하는 것이 오늘 포스팅의 목적입니다.
Secrets Manager 유형

Secret을 두 가지로 분리하여 관리하는 방식을 채택하였습니다.
- RDS 데이터베이스에 대한 자격 증명 : 패스워드 생명 주기 관리를 위한 암호화
- 다른 유형의 보안 암호 : 연결 정보(host, db name, port 등) 회전이 불필요한 정보들의 암호화.
왜 Secret을 분리할까?

의외로 Secret Manager 생성 방법은 간단합니다.

데이터베이스의 계정 이름과, 암호를 입력해주면 되고, kms는 미리 생성하시길 권장합니다.
위와 같이 작성 후 아래에서 Database를 선택해주시면 끝!

'다른 유형의 보안 암호' 생성 방식도 간단하며, Key:Vaule 형식으로 생성하시면 됩니다.
Secrets Manager가 생성이 되었다면 애플리케이션 단에서의 변경이 필요하겠죠 ?
03. 백엔드에서 코드 수정.
애플리케이션에서 Secrets Manager를 사용하는 방법은 두 가지가 있습니다.
- AWS SDK 라이브러리 설치 : composer로 설치하여, 간단히 구성 가능
- AWS API 직접 호출 : Helper 클래스를 직접 구성.
직접 API를 호출하는 방식을 선택한 이유는 현재 어플리케이션이 PHP로 구성이 되어 있는데
PHP 및 라이브러리들의 버전이 낮아서 SDK를 설치 시 composer 의존성 충돌이 일어나서 입니다..!
✅ Helper 클래스 작성
경로 : /var/www/html/lib/aws-secrets-helper.php
(경로는 별도로 라이브러리를 관리하는 디렉토리에 생성하면 되겠습니다)
<?php
/**
* AWS Secrets Manager Helper (No SDK Required)
* Uses AWS Signature Version 4 for authentication
*/
class AwsSecretsHelper {
private $region = 'ap-northeast-2';
private $service = 'secretsmanager';
public function getSecret($secretId) {
$credentials = $this->getInstanceCredentials();
$host = "secretsmanager.{$this->region}.amazonaws.com";
$endpoint = "https://{$host}/";
$payload = json_encode([
'SecretId' => $secretId
]);
$headers = [
'Content-Type' => 'application/x-amz-json-1.1',
'X-Amz-Target' => 'secretsmanager.GetSecretValue'
];
$signedHeaders = $this->signRequest(
'POST',
$endpoint,
$payload,
$headers,
$credentials
);
$ch = curl_init($endpoint);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, $signedHeaders);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if (curl_errno($ch)) {
$error = curl_error($ch);
curl_close($ch);
throw new Exception("CURL Error: {$error}");
}
curl_close($ch);
if ($httpCode !== 200) {
throw new Exception("AWS API Error (HTTP {$httpCode}): {$response}");
}
$result = json_decode($response, true);
if (!isset($result['SecretString'])) {
throw new Exception("Secret not found or invalid response");
}
return json_decode($result['SecretString'], true);
}
private function getInstanceCredentials() {
// EC2 Instance Metadata에서 IAM Role 자격 증명 가져오기
$ch = curl_init('http://169.254.169.254/latest/meta-data/iam/security-credentials/');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 2);
$roleName = curl_exec($ch);
curl_close($ch);
if (empty($roleName)) {
throw new Exception("No IAM role attached to EC2 instance");
}
$ch = curl_init("http://169.254.169.254/latest/meta-data/iam/security-credentials/{$roleName}");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 2);
$credentialsJson = curl_exec($ch);
curl_close($ch);
$credentials = json_decode($credentialsJson, true);
if (!isset($credentials['AccessKeyId'])) {
throw new Exception("Failed to retrieve instance credentials");
}
return $credentials;
}
private function signRequest($method, $endpoint, $payload, $headers, $credentials) {
// AWS Signature Version 4 구현
$url = parse_url($endpoint);
$host = $url['host'];
$datetime = gmdate('Ymd\THis\Z');
$date = gmdate('Ymd');
$canonicalUri = '/';
$canonicalQuerystring = '';
$canonicalHeaders = "content-type:application/x-amz-json-1.1\n";
$canonicalHeaders .= "host:{$host}\n";
$canonicalHeaders .= "x-amz-date:{$datetime}\n";
if (isset($credentials['Token'])) {
$canonicalHeaders .= "x-amz-security-token:{$credentials['Token']}\n";
}
$canonicalHeaders .= "x-amz-target:secretsmanager.GetSecretValue\n";
$signedHeaders = 'content-type;host;x-amz-date';
if (isset($credentials['Token'])) {
$signedHeaders .= ';x-amz-security-token';
}
$signedHeaders .= ';x-amz-target';
$payloadHash = hash('sha256', $payload);
$canonicalRequest = "{$method}\n{$canonicalUri}\n{$canonicalQuerystring}\n{$canonicalHeaders}\n{$signedHeaders}\n{$payloadHash}";
$algorithm = 'AWS4-HMAC-SHA256';
$credentialScope = "{$date}/{$this->region}/{$this->service}/aws4_request";
$stringToSign = "{$algorithm}\n{$datetime}\n{$credentialScope}\n" . hash('sha256', $canonicalRequest);
$kDate = hash_hmac('sha256', $date, 'AWS4' . $credentials['SecretAccessKey'], true);
$kRegion = hash_hmac('sha256', $this->region, $kDate, true);
$kService = hash_hmac('sha256', $this->service, $kRegion, true);
$kSigning = hash_hmac('sha256', 'aws4_request', $kService, true);
$signature = hash_hmac('sha256', $stringToSign, $kSigning);
$authorization = "{$algorithm} Credential={$credentials['AccessKeyId']}/{$credentialScope}, SignedHeaders={$signedHeaders}, Signature={$signature}";
$finalHeaders = [
'Content-Type: application/x-amz-json-1.1',
'Host: ' . $host,
'X-Amz-Date: ' . $datetime,
'X-Amz-Target: secretsmanager.GetSecretValue',
'Authorization: ' . $authorization
];
if (isset($credentials['Token'])) {
$finalHeaders[] = 'X-Amz-Security-Token: ' . $credentials['Token'];
}
return $finalHeaders;
}
}
Helper 클래스 특징
- EC2 Instance Metadata Service를 통한 자동 인증
- AWS Signature V4 직접 구현
- Session Token 지원 (Temporary Credentials)
- 최소 의존성 (curl, json만 필요)
3-1. DB 커넥션 파일 수정
<?php
/**
* DB 접속 정보 - AWS Secrets Manager 연동
* - username/password: RDS Secret (자동 회전)
* - host/port/dbname: 연결 정보 Secret (고정)
*/
function getDbCredentials() {
static $credentials = null;
if ($credentials !== null) {
return $credentials;
}
try {
require_once __DIR__ . '/../lib/aws-secrets-helper.php';
$helper = new AwsSecretsHelper();
// RDS Secret에서 username/password 가져오기
$rdsSecret = $helper->getSecret('이 부분은 Secrets Manager 이름 작성');
// 연결 정보 Secret에서 host/port/dbname 가져오기
$connSecret = $helper->getSecret('이 부분은 Secrets Manager 이름 작성');
// 두 Secret 병합
$credentials = [
'host' => $connSecret['host'],
'port' => $connSecret['port'],
'dbname' => $connSecret['dbname'],
'username' => $rdsSecret['username'],
'password' => $rdsSecret['password']
];
return $credentials;
} catch (Exception $e) {
error_log("Failed to get DB credentials: " . $e->getMessage());
die("Database configuration error.");
}
}
$creds = getDbCredentials();
$mysql_host = $creds['host'];
$mysql_user = $creds['username'];
$mysql_password = $creds['password'];
$mysql_db = $creds['dbname'];
$mysql_port = $creds['port'] ?? 3306;
주요 변경 사항
- getDbCredentials() 함수로 자격 증명 중앙화
- Static 캐싱으로 요청당 1회만 Secret 조회
- 에러 핸들링 강화 (로그 + 사용자 메시지)
04. IAM 권한 설정
EC2 Instance Role에 Secrets Manager 접근 권한을 추가해야 사용이 가능합니다.
Inline Policy를 사용하여 최소 권한 원칙으로 사용해도 되지만,
AWS 관리형 정책을 공유드릴테니 귀사 보안 정책에 맞게 사용하시면 되겠습니다.
'AWSSecretsManagerClientReadOnlyAccess' 관리형 정책을 사용하시면 됩니다.

설정을 완료하시어 실제 배포 테스트를 진행하시면, DB와 커넥션에 문제가 없는지 확인하시고
추가적으로 CloudTrail에 로깅 저장이 되는지도 확인하시면 가장 베스트입니다.

감사합니다.