CI에서 AWS로 배포할 때 가장 흔한 방식이 레포 Secrets에 AWS_ACCESS_KEY_ID와 AWS_SECRET_ACCESS_KEY를 박아두는 것이다. 운영하다 보면 이 키가 어느 레포에 몇 개나 퍼져 있는지, 마지막 로테이션이 언제였는지 아무도 모르는 상태가 된다. 키 유출 사고 대응을 해본 사람은 이 긴 수명 자격증명 하나가 얼마나 골치인지 안다. GitHub Actions는 OIDC 토큰을 발급하므로, 이 토큰으로 IAM 역할을 직접 맡으면 레포에 키를 둘 필요가 없다.
결론부터 말하자면, IAM에 GitHub을 OIDC 공급자로 한 번 등록하고 역할의 신뢰정책에서 sub 조건으로 특정 레포와 브랜치만 허용하면 된다. 워크플로는 aws-actions/configure-aws-credentials로 sts:AssumeRoleWithWebIdentity를 호출해 1시간짜리 임시 자격증명을 받는다. 레포 Secrets에서 액세스 키를 완전히 제거할 수 있다.
1. OIDC 페더레이션이 무엇인가
OIDC는 OpenID Connect의 약자로, 신뢰하는 외부 공급자가 서명한 토큰을 받아 자기 쪽 권한으로 교환하는 표준이다. GitHub Actions는 워크플로가 실행될 때마다 https://token.actions.githubusercontent.com이 서명한 짧은 수명의 JWT를 발급한다. 이 토큰에는 어떤 레포의 어떤 브랜치, 어떤 환경에서 돌고 있는지가 클레임으로 들어 있다.
AWS STS는 이 토큰을 검증하고 지정된 IAM 역할의 임시 자격증명으로 바꿔준다. 이 교환을 담당하는 STS API가 AssumeRoleWithWebIdentity다. 핵심은 교환 가능 여부를 IAM 역할의 신뢰정책이 결정한다는 점이다. 토큰의 클레임이 신뢰정책 조건과 맞아야만 역할을 맡을 수 있다.
2. 토큰 클레임과 sub 형식
신뢰정책 설계에서 가장 중요한 클레임이 sub(subject)다. 이 값으로 누가 역할을 맡을 수 있는지를 좁힌다. GitHub이 발급하는 토큰의 sub는 트리거 종류에 따라 형식이 다르다.
- 브랜치 푸시:
repo:OWNER/REPO:ref:refs/heads/BRANCH - 태그:
repo:OWNER/REPO:ref:refs/tags/TAG - 풀 리퀘스트:
repo:OWNER/REPO:pull_request - Environment 지정:
repo:OWNER/REPO:environment:ENV_NAME
예를 들어 sarc-io/deploy 레포의 main 브랜치에서 돌면 sub는 repo:sarc-io/deploy:ref:refs/heads/main이 된다. 또 하나의 클레임 aud(audience)는 토큰을 받을 대상인데, AWS로 쓸 때 기본값이 sts.amazonaws.com이다. 중국 리전은 sts.amazonaws.com.cn으로 달라진다.
3. IAM에 OIDC 공급자 등록
먼저 GitHub을 신뢰할 외부 공급자로 IAM에 한 번 등록한다. 콘솔에서는 IAM > Identity providers > Add provider > OpenID Connect로 들어가고, Provider URL에 https://token.actions.githubusercontent.com, Audience에 sts.amazonaws.com을 넣는다.
CLI로는 다음과 같다.
$ aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com
{
"OpenIDConnectProviderArn": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
}
예전에는 이 명령에 --thumbprint-list로 인증서 지문을 넣어야 했다. 지금은 IAM이 공급자의 TLS 인증서를 신뢰할 수 있는 CA로 검증하므로 지문을 지정해도 무시된다. 지문 만료 때문에 배포가 깨지던 과거 함정이 사라졌으니, 지문 관리는 신경 쓰지 않아도 된다.
계정 하나에 같은 Provider URL의 OIDC 공급자는 하나만 등록된다. 이미 있으면 EntityAlreadyExists가 나는데, 그때는 새로 만들지 말고 기존 ARN을 그대로 쓰면 된다.
4. 역할 신뢰정책의 sub 조건 설계
이제 워크플로가 맡을 IAM 역할을 만들고 신뢰정책을 붙인다. 신뢰정책은 어떤 토큰이 이 역할을 맡을 수 있는지를 규정하는 문서다. Action은 sts:AssumeRoleWithWebIdentity, Principal은 앞서 만든 OIDC 공급자 ARN이다.
특정 레포의 main 브랜치만 허용하는 신뢰정책은 다음과 같다.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:sarc-io/deploy:ref:refs/heads/main"
}
}
}
]
}
aud는 항상 StringEquals로 고정한다. aud 조건을 빼면 해당 공급자를 신뢰하는 다른 계정의 토큰까지 통과할 여지가 생기므로 반드시 넣는다.
sub를 StringEquals로 한 브랜치에 고정하면 가장 안전하다. 여러 브랜치나 태그를 허용하려면 StringLike와 와일드카드를 쓴다. 다만 범위를 넓힐수록 공격면이 커진다는 점을 염두에 둔다.
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:sarc-io/deploy:ref:refs/tags/v*"
}
}
여기서 피해야 할 설계가 repo:sarc-io/deploy:*처럼 통째로 여는 것이다. 이렇게 하면 그 레포의 모든 PR과 브랜치가 운영 배포 역할을 맡을 수 있다. 포크에서 올라온 PR이나 임의 브랜치까지 권한을 얻으므로, 운영 역할은 브랜치나 환경 단위로 좁혀야 한다.
배포 승인 게이트가 필요하면 GitHub Environments를 쓰고 sub를 repo:sarc-io/deploy:environment:production으로 묶는 방법이 깔끔하다. Environment에 보호 규칙을 걸어두면 승인 없이는 토큰의 sub가 그 값으로 발급되지 않는다.
CLI로 역할을 만들 때는 위 JSON을 파일로 저장해 넘긴다.
$ aws iam create-role \
--role-name gha-deploy \
--assume-role-policy-document file://trust-policy.json
$ aws iam attach-role-policy \
--role-name gha-deploy \
--policy-arn arn:aws:iam::aws:policy/AmazonS3FullAccess
권한 정책은 배포에 실제로 필요한 것만 최소로 붙인다. 위 예시의 S3 FullAccess는 설명용이고, 실무에서는 대상 버킷과 액션을 한정한 사용자 정의 정책을 쓴다.
5. 워크플로 설정
워크플로 쪽에서 반드시 챙길 것이 권한 블록이다. OIDC 토큰을 요청하려면 id-token: write가 있어야 한다. 이 권한이 없으면 토큰 요청 자체가 실패한다.
name: deploy
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/gha-deploy
role-session-name: gha-deploy
aws-region: ap-northeast-2
- run: aws sts get-caller-identity
- run: aws s3 sync ./dist s3://sarc-deploy-bucket --delete
configure-aws-credentials는 내부에서 GitHub의 core.getIDToken()으로 OIDC 토큰을 받아 AssumeRoleWithWebIdentity를 호출하고, 결과 자격증명을 뒤 스텝용 환경변수로 설정한다. audience를 지정하지 않으면 sts.amazonaws.com이 기본으로 쓰인다. 임시 자격증명의 기본 수명은 1시간이고, role-duration-seconds로 15분에서 12시간 사이로 조정한다.
get-caller-identity가 맡은 역할의 assumed-role ARN을 찍으면 연동이 된 것이다.
{
"UserId": "AROAEXAMPLEID:gha-deploy",
"Account": "123456789012",
"Arn": "arn:aws:sts::123456789012:assumed-role/gha-deploy/gha-deploy"
}
6. 자주 막히는 지점
가장 흔한 오류가 신뢰정책 불일치다. sub가 맞지 않으면 STS가 다음 메시지로 거부한다.
Error: Not authorized to perform sts:AssumeRoleWithWebIdentity
이때는 실패한 실행의 토큰 sub가 신뢰정책 조건과 글자 하나까지 같은지 본다. PR에서 돌렸는데 신뢰정책은 ref:refs/heads/main만 허용한 경우, 토큰의 sub는 repo:OWNER/REPO:pull_request라서 당연히 막힌다. 트리거와 조건을 맞춰야 한다.
워크플로에 permissions를 빠뜨리거나 id-token: write가 없으면 토큰을 못 받아 다음과 비슷한 오류가 난다.
Error: Could not assume role with OIDC: Credentials could not be loaded
조직 전체 기본 권한을 read-only로 묶어둔 경우, 잡 레벨에서 permissions를 명시적으로 다시 열어야 한다. 또 포크에서 올라온 PR은 보안상 쓰기 토큰이 제한되므로, 운영 배포는 포크 PR 트리거로 걸지 않는다.
aud 조건을 커스텀 값으로 바꿨다면 워크플로의 audience 입력도 같은 값으로 맞춰야 한다. 한쪽만 바꾸면 역시 AssumeRoleWithWebIdentity에서 거부된다.
7. 장수명 키 제거
OIDC 배포가 돌기 시작하면 기존 액세스 키를 정리한다. 레포 Settings > Secrets and variables > Actions에서 AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY를 삭제하고, 그 키가 달려 있던 IAM 사용자의 액세스 키를 비활성화한 뒤 사용 흔적이 없는지 확인하고 삭제한다.
$ aws iam list-access-keys --user-name ci-deployer
$ aws iam update-access-key \
--user-name ci-deployer \
--access-key-id AKIAEXAMPLE --status Inactive
# 며칠간 배포와 로그를 확인한 뒤 삭제
$ aws iam delete-access-key \
--user-name ci-deployer --access-key-id AKIAEXAMPLE
곧바로 지우기보다 Inactive로 두고 며칠 돌려본 뒤 삭제하는 편을 권한다. 그 키를 쓰던 다른 파이프라인이 남아 있을 수 있기 때문이다. CloudTrail에서 해당 액세스 키의 LastUsed를 확인하면 판단이 쉽다. 사용자에 다른 용도가 없으면 IAM 사용자 자체를 지워 자격증명을 남기지 않는다.