Published on

GitHub Actions OIDC AssumeRole 실패 원인과 해결

Authors

서버리스/CI 환경에서 정적 AWS 키를 없애기 위해 GitHub Actions의 OIDC(OpenID Connect)로 sts:AssumeRoleWithWebIdentity를 쓰는 구성이 사실상 표준이 됐습니다. 그런데 막상 적용하면 AssumeRole 단계에서 애매한 에러가 나고, AWS 콘솔/CloudTrail을 봐도 “권한 없음(AccessDenied)” 정도로만 보여서 삽질이 길어지기 쉽습니다.

이 글은 GitHub Actions OIDC로 AssumeRole이 실패할 때 가장 흔한 실패 지점을 로그/정책/토큰 관점으로 분해하고, 바로 적용 가능한 Trust Policy/Workflow 예제로 해결하는 방법을 정리합니다.

또한 CI 디버깅 관점은 GitHub Actions 캐시가 안 먹을 때 속도 3배 올린 실전처럼 “증상→원인→검증”의 흐름이 중요합니다. OIDC도 동일하게 증상(에러 메시지) 를 기준으로 원인 후보를 좁혀 가는 게 핵심입니다.

OIDC AssumeRole 흐름(정상 시나리오)

정상 흐름을 먼저 명확히 해두면, 어디서 끊겼는지 빠르게 찾을 수 있습니다.

  1. GitHub Actions 러너가 permissions: id-token: write 권한을 통해 OIDC 토큰(JWT)을 발급받음
  2. aws-actions/configure-aws-credentials가 그 토큰을 사용해 AWS STS에 AssumeRoleWithWebIdentity 호출
  3. AWS IAM Role의 Trust Policy가 해당 OIDC Provider(issuer)와 claim 조건(sub, aud)을 만족하면 임시 자격증명 발급
  4. 이후 AWS API 호출은 임시 자격증명으로 수행

실패는 보통 2~3번 경계에서 발생합니다.

대표 에러 메시지별 원인 맵

실제로 자주 보는 에러들을 “원인 후보”로 매핑해 두면, 디버깅 시간이 크게 줄어듭니다.

1) Not authorized to perform sts:AssumeRoleWithWebIdentity

  • Trust Policy에 sts:AssumeRoleWithWebIdentity가 없거나
  • OIDC Provider ARN/issuer가 잘못됐거나
  • Conditionsub/aud가 토큰과 불일치하거나
  • Role ARN을 잘못 지정했거나(다른 계정/다른 Role)

2) No OpenIDConnect provider found in your account for https://token.actions.githubusercontent.com

  • IAM에 OIDC Provider를 생성하지 않았거나
  • issuer URL이 다르거나(오타/슬래시) 다른 리전/계정에 만들어둠

3) The security token included in the request is invalid

  • id-token: write 권한이 없어서 토큰이 발급되지 않았는데, 워크플로가 그 상태로 진행
  • 액션 버전/환경변수 꼬임으로 잘못된 토큰 사용

4) AccessDenied인데 Trust는 맞는 것 같음

  • AssumeRole은 성공했지만, Role에 붙은 Permission Policy가 실제 AWS API 권한을 막음
  • 또는 세션 정책/permission boundary/SCP(Organizations)로 차단

1단계: GitHub Actions 워크플로 권한부터 확인

OIDC는 GitHub 쪽에서 토큰을 발급받는 권한이 필요합니다. 아래가 빠지면 100% 실패합니다.

name: deploy
on:
  push:
    branches: ["main"]

permissions:
  id-token: write   # OIDC 토큰 발급에 필수
  contents: read    # checkout 등에 필요

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials (OIDC)
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/gha-deploy-role
          aws-region: ap-northeast-2

      - name: Who am I
        run: aws sts get-caller-identity

여기서 permissions가 누락되면 configure-aws-credentials가 내부적으로 토큰을 못 받아오고, 결과적으로 STS 호출이 실패합니다.

디버깅 팁: 액션 로그 레벨 올리기

env:
  ACTIONS_STEP_DEBUG: true

또는 리포지토리 Settings에서 ACTIONS_STEP_DEBUG 시크릿을 true로 설정해 단계 로그를 더 자세히 볼 수 있습니다.

2단계: AWS IAM OIDC Provider 생성 확인

AWS 계정에 GitHub OIDC Provider가 있어야 합니다.

  • Issuer: https://token.actions.githubusercontent.com
  • Audience: 보통 sts.amazonaws.com

CLI로 확인:

aws iam list-open-id-connect-providers
aws iam get-open-id-connect-provider \
  --open-id-connect-provider-arn arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com

No OpenIDConnect provider found... 류의 에러면 이 단계가 1순위입니다.

3단계: Trust Policy(가장 흔한 실패 지점)

AssumeRole 실패의 70~80%는 Trust Policy의 Condition에서 발생합니다. 특히 sub 조건이 실제 워크플로의 컨텍스트와 조금이라도 다르면 바로 거절됩니다.

권장 Trust Policy 예시(최소 권한)

아래는 특정 리포지토리의 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:ORG/REPO:ref:refs/heads/main"
        }
      }
    }
  ]
}

여기서 자주 틀리는 포인트

  • ORG/REPO 대소문자 불일치
  • 브랜치가 main이 아닌데 main으로 고정
  • 태그 배포인데 ref:refs/tags/v1.2.3 형태를 고려하지 않음
  • PR 이벤트인데 ref가 다르게 나오는 케이스를 고려하지 않음

태그/브랜치/환경별로 조건을 유연하게 만들기

브랜치/태그 모두 허용하려면 StringLike를 사용합니다.

{
  "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"
    },
    "StringLike": {
      "token.actions.githubusercontent.com:sub": [
        "repo:ORG/REPO:ref:refs/heads/*",
        "repo:ORG/REPO:ref:refs/tags/*"
      ]
    }
  }
}

운영에서는 보통 heads/*를 열기보다는, 배포 브랜치/태그만 명시적으로 좁히는 편이 안전합니다.

4단계: 실제 OIDC 토큰의 claim 확인(재현 가능한 진단)

Trust Policy의 sub/aud가 맞는지 확인하려면, “내가 생각하는 값”이 아니라 실제 발급된 JWT의 claim을 확인해야 합니다.

GitHub에서 OIDC 토큰을 직접 받아 디코딩하는 예시입니다(디버깅용). 토큰은 민감정보이므로 로그에 그대로 남기지 않도록 주의하세요.

- name: Mint OIDC token (debug)
  id: oidc
  run: |
    echo "Requesting OIDC token..."
    resp=$(curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
      "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com")
    echo "$resp" | jq -r '.value' > token.jwt

    # JWT payload만 추출(로그 최소화)
    python3 - <<'PY'
import json,base64
jwt=open('token.jwt').read().strip()
parts=jwt.split('.')
payload=parts[1] + '=='
print(json.dumps(json.loads(base64.urlsafe_b64decode(payload)), indent=2))
PY

출력에서 아래를 확인합니다.

  • audsts.amazonaws.com인지
  • subrepo:ORG/REPO:ref:... 형태로 무엇인지
  • repository, ref, actor 등이 기대한 값인지

이 결과와 Trust Policy의 조건을 1:1로 맞추면 대부분 해결됩니다.

5단계: AssumeRole은 성공했는데 AWS API가 실패하는 경우

aws sts get-caller-identity는 되는데, 예를 들어 S3 업로드나 ECR 푸시가 실패한다면, OIDC/Trust 문제가 아니라 Role Permission Policy 문제입니다.

예: ECR 푸시 권한이 빠진 경우

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "ecr:GetAuthorizationToken",
        "ecr:BatchCheckLayerAvailability",
        "ecr:InitiateLayerUpload",
        "ecr:UploadLayerPart",
        "ecr:CompleteLayerUpload",
        "ecr:PutImage"
      ],
      "Resource": "*"
    }
  ]
}

또한 AWS Organizations의 SCP, IAM Permission Boundary, Session Policy가 끼어 있으면 “정책은 있어 보이는데 계속 AccessDenied”가 발생할 수 있습니다. 이때는 CloudTrail에서 거절 원인을 확인하는 게 가장 빠릅니다.

6단계: CloudTrail로 실패 원인 확정하기

AssumeRole 실패는 AWS 쪽에서 최종 판정이 나므로, CloudTrail 이벤트로 보면 결론이 빨라집니다.

  • Event source: sts.amazonaws.com
  • Event name: AssumeRoleWithWebIdentity
  • Error code/message와 request parameters 확인

특히 sub mismatch는 CloudTrail의 request/response만으론 직접적으로 “sub가 달라서 실패”라고 친절히 안 나오는 경우가 많습니다. 그럴 땐 위에서 소개한 토큰 claim 디코딩과 병행해야 합니다.

실전 체크리스트(10분 컷)

  1. 워크플로에 permissions: id-token: write 있는가
  2. aws-actions/configure-aws-credentials@v4 사용 중인가(구버전 이슈 방지)
  3. AWS 계정에 token.actions.githubusercontent.com OIDC Provider가 존재하는가
  4. Trust Policy의 Principal.Federated ARN이 정확한가
  5. Trust Policy의 audsts.amazonaws.com으로 일치하는가
  6. Trust Policy의 sub 조건이 실제 토큰의 sub와 일치하는가(브랜치/태그/PR 이벤트 포함)
  7. AssumeRole 성공 후 API 실패라면 Role Permission Policy/SCP/Boundary를 의심

CI 문제는 원인을 좁혀가는 과정이 중요합니다. 캐시 문제를 진단할 때도 단계별로 관찰 지점을 만드는 게 핵심이듯(GitHub Actions 캐시가 안 먹을 때 속도 3배 올린 실전), OIDC도 토큰 발급 → STS 호출 → Trust Policy 조건 매칭 → 권한 정책 순서로 나누면 거의 항상 답이 나옵니다.

마무리: 가장 안전한 운영 패턴

  • Trust Policy의 sub는 가능한 한 리포지토리 + 배포 브랜치/태그로 좁히기
  • StringLike로 와일드카드를 쓰더라도 범위를 최소화
  • 워크플로 이벤트(푸시/태그/PR/환경 승인)에 따라 sub가 달라질 수 있음을 전제로 설계
  • 디버깅 시에는 토큰 claim을 직접 확인해 “내가 믿는 값”을 “실제 값”으로 교체

이 패턴으로 구성하면 GitHub Actions OIDC AssumeRole 실패는 대부분 짧게 끝낼 수 있고, 정적 키 없이도 안전한 배포 파이프라인을 운영할 수 있습니다.