IT/CI CD

GitHub Actions OIDC로 AWS 배포하기 — 2026년 immutable subject 반영

gfrog 2026. 8. 11. 00:11

왜 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에 워크플로우 정보를 태울 수도 있다.

큰 그림

세 개만 만들면 된다.

  1. AWS 계정에 GitHub의 OIDC provider 등록 (token.actions.githubusercontent.com)
  2. 배포용 IAM Role 하나. Trust policy에 "이 리포의 이 브랜치/환경에서 오는 토큰만 허용" 조건을 건다
  3. 워크플로우에서 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-namerun_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 기반 필터로 하고 있는데 좀 지저분하다.