Published on

GitHub Actions OIDC로 AWS AssumeRoleAccessDenied 해결

Authors

서버리스/컨테이너 배포 파이프라인을 GitHub Actions로 옮기면서 가장 흔하게 마주치는 에러 중 하나가 AssumeRoleAccessDenied입니다. 기존에는 장기 액세스 키를 리포지토리 시크릿에 넣고 aws configure로 해결했지만, 보안/감사 요구가 커지면서 **OIDC(OpenID Connect) 기반의 단기 자격증명(AssumeRoleWithWebIdentity)**가 사실상 표준이 됐습니다.

문제는 OIDC로 전환할 때 AWS IAM 신뢰 정책(trust policy)과 GitHub 토큰 클레임(sub, aud 등)이 조금만 어긋나도, 로그에는 단 한 줄로만 실패가 찍히는 경우가 많다는 점입니다.

이 글은 “왜 거부됐는지”를 재현 가능한 체크리스트로 분해하고, 실제로 동작하는 IAM + GitHub Actions 설정 예제까지 한 번에 정리합니다.

> 참고로, CI/CD에서의 진단 접근은 네트워크/애플리케이션 타임아웃을 파고드는 방식과 유사합니다. 증상을 분해하고(어디서 거부되는지), 관측 가능한 신호(클레임/정책/권한)를 늘린 뒤, 최소 변경으로 원인을 좁혀갑니다. 비슷한 진단 관점은 Spring Boot 3 Feign 타임아웃·재시도 함정 9가지에서도 도움이 됩니다.

AssumeRoleAccessDenied가 의미하는 것

OIDC로 AWS 역할을 Assume할 때 GitHub Actions는 대략 다음 순서로 움직입니다.

  1. GitHub가 워크플로 실행에 대해 OIDC 토큰(JWT)을 발급한다.
  2. aws-actions/configure-aws-credentials가 그 토큰을 AWS STS에 제출한다.
  3. AWS STS가 IAM Role의 신뢰 정책(누가 Assume할 수 있는가)을 검사한다.
  4. 통과하면 단기 자격증명(AccessKey/Secret/SessionToken)을 발급한다.
  5. 이후 AWS API 호출은 그 자격증명으로 진행한다.

따라서 AssumeRoleAccessDenied는 대부분 **3번(신뢰 정책)**에서 막힌 경우입니다. 다만 로그에 따라 다음 두 케이스가 섞여 보일 수 있습니다.

  • 신뢰 정책 불일치: Not authorized to perform sts:AssumeRoleWithWebIdentity
  • 역할 자체 권한/경계/조직 정책 문제: Assume은 됐지만 이후 AccessDenied가 발생

이 글은 특히 첫 번째(Assume 자체가 거부) 문제를 중심으로 다룹니다.

가장 흔한 원인 7가지 (우선순위 순)

1) GitHub Actions에 OIDC 권한이 없음 (permissions: id-token: write)

OIDC 토큰을 받으려면 워크플로에 다음이 필요합니다.

permissions:
  id-token: write
  contents: read

이게 없으면 액션이 토큰을 못 받아 STS 호출 자체가 실패하거나, 내부적으로 다른 형태의 오류가 납니다. 특히 조직/리포지토리에서 기본 권한을 read로 잠가둔 경우 자주 놓칩니다.

2) IAM Role 신뢰 정책에서 Principal.Federated가 잘못됨

OIDC 제공자는 아래 ARN 형태여야 합니다.

  • arn:aws:iam::<ACCOUNT_ID>:oidc-provider/token.actions.githubusercontent.com

간혹 https://token.actions.githubusercontent.com 같이 URL을 넣거나, provider를 다른 계정에 만들어두고 역할은 현재 계정에 만드는 실수가 있습니다.

3) aud(Audience) 조건 불일치

GitHub OIDC 토큰의 기본 aud는 대개 sts.amazonaws.com입니다. 신뢰 정책에서 StringEqualstoken.actions.githubusercontent.com:aud를 제한해두고 값이 다르면 거부됩니다.

4) sub(Subject) 조건 불일치: 브랜치/태그/환경(environment) 구분

가장 많이 틀리는 부분이 sub입니다. sub는 대략 이런 패턴을 가집니다.

  • 브랜치 기준: repo:<OWNER>/<REPO>:ref:refs/heads/<BRANCH>
  • 태그 기준: repo:<OWNER>/<REPO>:ref:refs/tags/<TAG>
  • GitHub Environment 기준(설정에 따라): repo:<OWNER>/<REPO>:environment:<ENV_NAME>

신뢰 정책을 main 브랜치로 고정해두고, 실제 배포는 release/*에서 해서 거부되는 식의 사고가 잦습니다.

5) 리포지토리/조직 이름 대소문자·오타

repo:Owner/Repo에서 Owner/Repo는 정확히 일치해야 합니다. 특히 조직 리네임, 포크 후 적용, 모노레포 분리 시에 자주 발생합니다.

6) STS 엔드포인트/리전 혼동(특히 Gov/China)

상용 AWS가 아니라 GovCloud/China 리전이면 OIDC 제공자 URL/STS 엔드포인트가 달라질 수 있습니다. 대부분은 상용 AWS에서 발생하므로 이 케이스는 빈도가 낮지만, 해당 환경이라면 먼저 확인하세요.

7) SCP(Organizations) 또는 Permission Boundary로 STS 호출이 차단

신뢰 정책이 맞아도 계정에 SCP가 걸려 sts:AssumeRoleWithWebIdentity를 막으면 동일한 증상이 납니다. 이 경우 CloudTrail에서 거부 주체가 SCP인지 확인해야 합니다.

“정답 설정” 예제: IAM OIDC Provider + Role Trust Policy

1) OIDC Provider 생성(한 번만)

CLI로 생성하는 예시입니다.

aws iam create-open-id-connect-provider \
  --url https://token.actions.githubusercontent.com \
  --client-id-list sts.amazonaws.com \
  --thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1
  • client-id-list는 신뢰 정책에서 aud 제한과 연결됩니다.
  • thumbprint는 AWS 문서/가이드를 따르되, 조직 정책에 따라 IaC(Terraform/CloudFormation)로 관리하는 것을 권장합니다.

2) Role 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:my-org/my-repo:ref:refs/heads/main"
        }
      }
    }
  ]
}

브랜치가 여러 개라면 StringLike로 패턴 매칭을 씁니다.

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

> 팁: sub를 과하게 넓히면(예: repo:my-org/my-repo:*) 편하긴 하지만, PR/임의 브랜치에서도 배포 역할을 Assume할 수 있는 보안 리스크가 생깁니다. 최소 권한 원칙으로 좁혀두세요.

GitHub Actions 워크플로 예제(동작 보장 형태)

아래 예제는 OIDC로 역할을 Assume한 뒤 S3에 업로드하는 단순 배포 흐름입니다.

name: deploy

on:
  push:
    branches: ["main"]

permissions:
  id-token: write
  contents: read

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/github-actions-deploy
          aws-region: ap-northeast-2

      - name: Verify caller identity
        run: aws sts get-caller-identity

      - name: Upload artifact
        run: aws s3 cp ./dist s3://my-bucket/dist/ --recursive

여기서 실패한다면, 문제는 거의 항상 아래 둘 중 하나입니다.

  • permissions 누락
  • Role trust policy의 sub/aud 불일치

빠른 진단: “sub가 뭐로 들어오는지” 확인하기

신뢰 정책을 맞추려면, 실제 워크플로에서 발급되는 OIDC 토큰의 클레임을 확인하는 게 가장 빠릅니다. GitHub는 ACTIONS_ID_TOKEN_REQUEST_URLACTIONS_ID_TOKEN_REQUEST_TOKEN 환경 변수를 제공하므로, 토큰을 받아 JWT payload를 출력할 수 있습니다.

> 주의: 토큰 원문을 로그에 그대로 출력하면 보안상 위험합니다. 아래는 payload만 최소로 확인하는 예시이며, 실제 운영에서는 필요한 순간에만 사용하고 즉시 제거하세요.

- name: Dump OIDC token claims (debug)
  shell: bash
  run: |
    set -euo pipefail
    resp=$(curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
      "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com")
    token=$(python -c 'import json,sys; print(json.load(sys.stdin)["value"])' <<< "$resp")

    payload=$(python - <<'PY'
import os, json, base64
import sys

token = os.environ["TOKEN"]
parts = token.split(".")
payload = parts[1] + "==="
print(base64.urlsafe_b64decode(payload).decode())
PY
    )

    echo "$payload" | jq '{sub, aud, repository, ref, sha, actor, repository_owner}'
  env:
    TOKEN: ${{ steps.idtoken.outputs.token }}

위처럼 구현하려면 토큰을 step output으로 빼야 하는데, 일반적으로는 간단히 curl 응답에서 token을 받아 같은 step에서 payload만 디코드하는 방식으로 충분합니다. 핵심은 sub가 기대와 다르게 나오는지를 확인하는 것입니다.

또 다른 실전 팁은 CloudTrail을 보는 것입니다.

  • 이벤트 소스: sts.amazonaws.com
  • 이벤트 이름: AssumeRoleWithWebIdentity
  • 실패 사유/조건 불일치가 좀 더 명확히 남는 경우가 많음

흔한 실수 패턴별 해결 레시피

패턴 A: main에서만 되는데 release 브랜치에서 실패

  • 증상: main push는 성공, release/1.2.3AssumeRoleAccessDenied
  • 원인: trust policy가 refs/heads/main으로 고정
  • 해결: StringLikerefs/heads/release/* 추가
"StringLike": {
  "token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:ref:refs/heads/release/*"
}

패턴 B: PR에서는 반드시 막고 싶은데, 가끔 PR에서 Assume이 됨

  • 원인: sub를 너무 넓게 열었거나, pull_request 이벤트에서도 같은 역할을 사용
  • 해결:
    • 배포 워크플로는 push(보호 브랜치)에서만 실행
    • trust policy에서 ref 기반으로 제한
    • 필요하면 GitHub Environment + Required reviewers로 사람 승인 추가

패턴 C: Assume은 성공했는데 이후 S3/ECR에서 AccessDenied

이건 AssumeRoleAccessDenied가 아니라 역할 정책(permissions policy) 문제입니다.

  • aws sts get-caller-identity는 성공
  • aws s3 cp 또는 aws ecr get-login-password에서 거부

해결은 역할에 붙은 정책을 점검하면 됩니다. 예를 들어 ECR 푸시가 필요하면:

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

단, Resource*로 두는 대신 특정 리포지토리 ARN으로 좁히는 것이 바람직합니다.

운영 품질을 올리는 권장사항

1) 역할을 “환경별로 분리”하고 sub도 환경에 맞춰 제한

예:

  • github-actions-deploy-dev
  • github-actions-deploy-prod

그리고 prod는 GitHub Environment prod에서만 실행되도록 하고, trust policy도 다음처럼 제한합니다.

"StringEquals": {
  "token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:environment:prod"
}

이렇게 하면 브랜치 규칙만으로는 막기 어려운 “실수 배포”를 한 번 더 방지할 수 있습니다.

2) 디버그는 짧게, 관측은 구조적으로

토큰 클레임 출력은 강력하지만 민감합니다. 운영에서는 CloudTrail, IAM Access Analyzer, 정책 시뮬레이터를 활용해 로그에 민감정보를 남기지 않고 원인을 좁히는 게 좋습니다. 장애/오류를 다룰 때 “재시도”나 “백오프”로 덮기보다, 먼저 원인 신호를 늘리는 접근이 중요합니다. 이 관점은 OpenAI 429/Rate Limit 대응 - 재시도·백오프·큐잉 같은 글에서 다룬 패턴과도 통합니다.

3) EKS 배포 파이프라인이라면 권한 범위를 더 촘촘히

EKS에 배포하는 경우(예: kubectl, helm)는 IAM Role이 EKS API에 접근하는 것뿐 아니라, 클러스터 내부 RBAC까지 연동됩니다. OIDC Assume 문제를 해결한 뒤에도 배포가 막히면, 다음 단계는 대개 Kubernetes 인증/인가 쪽입니다. EKS 운영 이슈 전반의 진단 감각은 EKS CrashLoopBackOff 진단 - Pod 재시작 원인 추적 같은 글과 함께 보면 흐름을 잡기 좋습니다.

체크리스트: 3분 안에 결론 내기

  1. 워크플로에 permissions: id-token: write가 있는가?
  2. role-to-assume ARN이 맞는 계정/역할인가?
  3. IAM에 OIDC Provider가 있고 URL이 token.actions.githubusercontent.com인가?
  4. Role trust policy의 Principal.Federated가 OIDC Provider ARN과 일치하는가?
  5. trust policy에서 aud == sts.amazonaws.com인가?
  6. trust policy의 sub가 실제 실행 컨텍스트(ref/environment)와 일치하는가?
  7. CloudTrail에서 AssumeRoleWithWebIdentity 실패 사유가 조건 불일치인지, SCP/Boundary인지 확인했는가?

마무리

GitHub Actions OIDC + AWS STS AssumeRoleWithWebIdentity 조합에서 AssumeRoleAccessDenied는 대부분 신뢰 정책의 조건(aud/sub) 미스매치로 귀결됩니다. 해결의 핵심은 “정책을 넓혀서 일단 되게 만들기”가 아니라, 실제 토큰 클레임을 기준으로 최소 권한으로 정확히 맞추기입니다.

위 예제의 trust policy와 워크플로 템플릿을 기준으로 적용한 뒤, 브랜치/태그/Environment 전략에 맞춰 sub 조건을 설계하면 재발을 크게 줄일 수 있습니다.