GitHub Actions OIDC로 AWS 배포하기 — 2026년 immutable subject 반영
왜 OIDC인가 — 30초 요약
한동안 "AWS Access Key를 Secrets에 넣지 마세요"가 마치 mantra처럼 돌았다. 대안은 오래전부터 있었다. GitHub Actions의 OIDC 토큰을 AWS IAM Role의 web identity federation으로 교환하는 방식. 우리 팀도 재작년쯤 다 옮겼는데, 최근 이걸 다시 손 볼 일이 생겼다. 2026년 4월과 7월에 걸쳐 GitHub가 OIDC 토큰의 sub 클레임 포맷을 immutable ID 기반으로 바꿨기 때문이다. 새로 만드는 리포는 자동으로 새 포맷을 쓴다. 기존에 잘 돌던 IAM trust policy가 새 리포에서는 안 붙는다는 얘기다.
이 글은 처음 세팅하는 사람과, 이미 쓰고 있지만 최근 트러스트 정책이 실패한 사람 둘 다 겨냥해서 정리했다.
Access Key/Secret Key를 GitHub Secrets에 저장하면 세 가지가 아프다. 첫째, 키 로테이션을 사람이 챙겨야 한다. 둘째, 리포 컴프로마이즈되면 장기 유효한 키가 유출된다. 셋째, CloudTrail에서 "이 API 콜 누가 했냐" 볼 때 IAM User 이름만 보여서 어느 워크플로우인지 추적이 안 된다.
OIDC를 쓰면 GitHub Actions 실행마다 짧게 만료되는 JWT를 발급받고, AWS는 그걸 IAM Role로 교환해준다. 키가 리포에 없다. CloudTrail의 userIdentity.sessionIssuer.arn에 Role 이름이 찍히고, sourceIdentity에 워크플로우 정보를 태울 수도 있다.
큰 그림
세 개만 만들면 된다.
- AWS 계정에 GitHub의 OIDC provider 등록 (
token.actions.githubusercontent.com) - 배포용 IAM Role 하나. Trust policy에 "이 리포의 이 브랜치/환경에서 오는 토큰만 허용" 조건을 건다
- 워크플로우에서
aws-actions/configure-aws-credentials로 Role을 assume
1단계: OIDC provider 등록
Terraform 기준.
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = ["ffffffffffffffffffffffffffffffffffffffff"]
}
thumbprint_list는 예전에는 손으로 계산해서 넣었는데, 요즘은 AWS가 인증서 체인을 자체 검증해서 값이 무의미해졌다. 그래도 필드는 남아 있어서 아무 40자 hex를 넣으면 된다. 계정당 한 번만 만들면 되고, 이미 있으면 재사용한다.
2단계: IAM Role과 Trust policy — 여기가 2026년에 바뀐 곳
Trust policy가 이 글의 핵심이다. 예전 문서를 그대로 따라 만들면 새 리포에서 안 붙는다. sub 클레임 포맷이 바뀌었기 때문이다.
옛날 포맷 (2026-07-15 이전에 만들어진 리포, 옵트인 안 함):
repo:octo-org/octo-repo:ref:refs/heads/main
새 포맷 (2026-07-15 이후 생성 또는 옵트인):
repo:octo-org@123/octo-repo@456:ref:refs/heads/main
숫자는 조직/리포의 immutable ID다. 조직명이나 리포명을 바꿔도 이 ID는 유지된다. 반대로 조직 하나 없어지고 같은 이름으로 새로 만들면 ID는 달라진다. 이걸 트러스트에 걸어두면 "이름만 같은 남의 리포"가 우리 롤을 뺏어가는 시나리오가 원천 차단된다. 실제로 이 취약점이 리포트되어서 GitHub가 포맷을 바꿨다.
구분자가 예전 문서에는 하이픈(-)으로 나와 있었는데 최종적으로 @로 확정됐다. 6월 10일자 변경. 옛날 예제 복붙하다가 안 붙으면 여기 의심해보자.
리포와 조직 ID는 GitHub API로 확인한다.
gh api /repos/{owner}/{repo} --jq '{owner_id: .owner.id, repo_id: .id}'
Trust policy 예시. 특정 브랜치만 허용하는 케이스:
{
"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:kurly-org@1234567/blog-writer@9876543:ref:refs/heads/main"
}
}
}]
}
Environment 기반으로 잠그는 게 사실은 더 안전하다. 브랜치 조건은 검토 없이 push하면 통과되는데, environment는 approval을 붙일 수 있다.
"token.actions.githubusercontent.com:sub": "repo:kurly-org@1234567/blog-writer@9876543:environment:production"
여러 브랜치나 환경을 허용하려면 StringLike로 와일드카드를 쓴다.
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:kurly-org@1234567/blog-writer@9876543:*"
}
이건 편한데 너무 헐렁하다. PR에서 아무나 pull_request 이벤트로 실행하면 그것도 통과한다. 우리 팀에서는 environment 조건 + ref 조건 조합으로 좁힌다.
3단계: 워크플로우
name: deploy
on:
push:
branches: [main]
permissions:
id-token: write # OIDC 토큰 발급에 필수
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
aws-region: ap-northeast-2
role-session-name: gha-${{ github.run_id }}
- run: aws sts get-caller-identity
id-token: write가 없으면 OIDC 토큰 자체가 발급 안 된다. 이거 빼먹고 "왜 안 붙지" 삽질하는 사람이 매번 나온다. 리포 기본 permission이 read-only인 조직에서 특히.
role-session-name에 run_id를 넣으면 CloudTrail에서 어느 워크플로우 실행이었는지 찾기 좋다. 우리는 여기 브랜치 이름도 같이 태운다.
기존 리포를 새 포맷으로 옮기기
Settings → Actions → General → OpenID Connect 아래 토글이 있다. 조직 단위로도 옵트인 가능. 켜는 순간 새 포맷으로만 토큰이 나가니까, IAM trust policy를 먼저 새 포맷으로 바꿔놓고 켜야 한다. 반대 순서로 하면 5분 정도 배포가 멈춘다. 롤백 가능하니 큰 문제는 아니지만 굳이.
옵트인 안 하고 그냥 두면 옛 포맷을 계속 쓸 수 있긴 하다. 근데 새 리포 만들 때마다 "옛 포맷 강제" 옵션을 신경써야 하고, 결국은 마이그레이션할 거라서 우리 팀은 그냥 조직 전체를 한 번에 옵트인했다.
자주 밟는 지뢰 몇 개
"Not authorized to perform sts:AssumeRoleWithWebIdentity" — 십중팔구 sub 클레임 mismatch다. configure-aws-credentials가 실패해도 실제 토큰의 sub값은 안 찍어준다. 디버깅하려면 워크플로우에 이걸 넣어서 실제 토큰의 클레임을 본다.
- run: |
IDTOKEN=$(curl -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -r .value)
echo "$IDTOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq .
env:
ACTIONS_ID_TOKEN_REQUEST_TOKEN: ${{ env.ACTIONS_ID_TOKEN_REQUEST_TOKEN }}
ACTIONS_ID_TOKEN_REQUEST_URL: ${{ env.ACTIONS_ID_TOKEN_REQUEST_URL }}
토큰이 로그에 찍히긴 하지만 5분 만료라 실질적 위험은 낮다. 그래도 프로덕션 롤 디버깅에는 별도 dev 롤 만들어서 하는 걸 권한다.
Reusable workflow에서 안 붙는 문제 — reusable workflow를 다른 리포에서 호출하면 토큰의 job_workflow_ref 클레임이 호출된 워크플로우 기준이 된다. 이걸 조건에 걸어야 할 때가 있다.
Region 미스매치 — Trust policy와 워크플로우의 region은 별개다. STS assume은 글로벌이라 상관없지만, assume 후 호출하는 API가 region-specific이면 그건 워크플로우의 aws-region 설정을 따른다.
마무리
새 리포를 만들 일이 있으면 이번 참에 OIDC부터 세팅해두는 게 낫다. 옛날 포맷으로 만들어두고 나중에 immutable 포맷으로 갈아엎느니, 처음부터 immutable로 짜는 게 편하다. Access Key를 아직 Secrets에 넣어 쓰고 있다면 그것부터 없애자. 로테이션 알림 무시하다가 사고 나기 전에.
혹시 monorepo에서 서브 프로젝트별로 다른 롤 쓰는 패턴 하는 분 있으면 어떻게 하는지 궁금하다. 우리는 job_workflow_ref + path 기반 필터로 하고 있는데 좀 지저분하다.