Published on

GitHub Actions OIDC로 AWS AssumeRole 403 해결

Authors

서버리스/컨테이너 배포 파이프라인을 GitHub Actions로 구성할 때, 장기 액세스 키를 없애기 위해 OIDC(OpenID Connect) 기반 AssumeRole을 붙이는 경우가 많습니다. 그런데 설정이 조금만 어긋나도 AssumeRoleWithWebIdentity 단계에서 403(대개 AccessDenied)이 터지고, 로그에는 원인을 직접적으로 말해주지 않아 디버깅 시간이 길어집니다.

이 글은 GitHub Actions OIDC → AWS STS AssumeRoleWithWebIdentity 플로우에서 발생하는 403을 원인 유형별로 분류하고, 어떤 로그/클레임을 확인해야 하는지, 그리고 정답에 가까운 Trust Policy/Permissions 예시를 제공합니다.

> 참고: EKS까지 이어지는 배포 파이프라인을 운영한다면, 인증 이슈 이후 실제 클러스터 작업에서 또 다른 장애가 나기 쉽습니다. 예를 들어 업그레이드 이후 권한 매핑이 꼬이는 케이스는 Terraform로 EKS 업그레이드 후 aws-auth 꼬임으로 노드 Join 실패 해결도 함께 보면 흐름이 이어집니다.

1) GitHub Actions OIDC → AWS AssumeRole 흐름 한 장으로 정리

GitHub Actions에서 aws-actions/configure-aws-credentials 액션을 쓰면 내부적으로 다음이 일어납니다.

  1. 워크플로가 permissions: id-token: write 권한으로 GitHub OIDC 토큰(JWT)을 발급
  2. 액션이 그 JWT를 들고 AWS STS의 AssumeRoleWithWebIdentity 호출
  3. AWS IAM Role의 Trust Policy가 JWT의 발급자(issuer)/대상(audience)/주체(subject) 등을 조건으로 검증
  4. 통과하면 STS 임시 자격증명(AccessKeyId/SecretAccessKey/SessionToken) 발급
  5. 이후 AWS API 호출은 이 임시 자격증명으로 수행

403은 보통 3번(신뢰 정책 불일치) 또는 **4번 이후(권한 정책 부족)**에서 발생합니다. 먼저 “어디에서 막혔는지”를 분리하는 게 핵심입니다.

2) 403의 대표 증상과 에러 메시지 패턴

A. Not authorized to perform sts:AssumeRoleWithWebIdentity

  • 신뢰 정책(Trust Policy) 문제일 가능성이 가장 큼
  • issuer/provider ARN, aud, sub 조건 불일치

B. AccessDenied인데 AssumeRole은 성공하고, 이후 서비스 호출에서 실패

  • 예: AccessDenied: User is not authorized to perform: ecr:PutImage
  • 이 경우는 Role에 붙은 권한 정책(permissions policy) 문제

C. InvalidIdentityToken, No OpenIDConnect provider found

  • OIDC Provider 리소스가 없거나, issuer URL이 다름
  • thumbprint/issuer mismatch 등(요즘은 thumbprint를 직접 신경 쓸 일이 줄었지만 계정/리전에 따라 레거시 설정이 남아있는 경우가 있음)

이 글의 초점은 질문 주제대로 AssumeRole 단계 403(A 유형)을 중심으로 풀어갑니다.

3) 가장 흔한 원인 1: GitHub Actions에서 id-token 권한 누락

OIDC 토큰 자체를 못 받으면 액션이 다른 방식으로 동작하거나(환경에 따라) 토큰이 비어 STS 호출이 실패합니다. 워크플로 최상단 또는 job 레벨에 다음이 있어야 합니다.

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

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

체크 포인트:

  • permissions가 누락되면 id-token 발급이 안 됩니다.
  • 조직/리포지토리 설정에서 Workflow permissions가 제한되어 있으면 토큰이 막힐 수 있습니다.

4) 가장 흔한 원인 2: IAM OIDC Provider(issuer) 설정 불일치

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

  • Issuer: https://token.actions.githubusercontent.com
  • Provider ARN 예시: arn:aws:iam::<ACCOUNT_ID>:oidc-provider/token.actions.githubusercontent.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

여기서 issuer가 다르거나 provider가 없다면, Trust Policy가 아무리 맞아도 No OpenIDConnect provider found 또는 유사한 실패로 이어집니다.

5) 가장 흔한 원인 3: Trust Policy의 sub 조건이 실제 클레임과 다름

OIDC 403의 80%는 여기서 납니다.

GitHub OIDC 토큰의 sub는 보통 다음 패턴입니다.

  • 브랜치 푸시: repo:<OWNER>/<REPO>:ref:refs/heads/<BRANCH>
  • 태그: repo:<OWNER>/<REPO>:ref:refs/tags/<TAG>
  • PR: repo:<OWNER>/<REPO>:pull_request
  • 환경(Environment) 사용 시: repo:<OWNER>/<REPO>:environment:<ENV_NAME>

즉, Trust Policy를 main 브랜치만 허용하도록 만들어놓고, 실제로는 release/* 브랜치에서 돌리면 403이 납니다.

권장 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/release/*"
        }
      }
    }
  ]
}

실무 팁:

  • 처음에는 딱 하나의 repo + 딱 하나의 브랜치로 좁게 열고 성공시킨 뒤, 점진적으로 StringLike로 확장하세요.
  • subrepo:my-org/*처럼 과도하게 풀면, 의도치 않은 리포지토리에서 Role을 가져갈 수 있습니다.

6) 가장 흔한 원인 4: aud(Audience) 불일치

GitHub OIDC 토큰의 기본 audience는 액션/설정에 따라 다를 수 있지만, AWS STS 연동에서 표준은 보통 sts.amazonaws.com입니다.

Trust Policy에 다음이 있는데,

"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"

워크플로/액션이 다른 audience로 토큰을 발급받으면 403이 납니다.

aws-actions/configure-aws-credentials@v4는 기본적으로 AWS에 맞는 audience를 사용하지만, 커스텀으로 토큰을 발급하거나 다른 액션을 섞는 경우 aud가 달라질 수 있습니다.

7) 디버깅: 실제 OIDC 클레임 확인하기

403이 날 때 가장 빠른 해결책은 “내가 허용한다고 생각한 sub/aud”와 “실제 토큰의 sub/aud”를 비교하는 것입니다.

GitHub Actions에서 OIDC 토큰을 직접 받아 디코딩하는 방식으로 확인할 수 있습니다.

- name: Dump OIDC token claims (debug)
  shell: bash
  run: |
    set -euo pipefail
    echo "Requesting OIDC token..."
    TOKEN_JSON=$(curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
      "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=sts.amazonaws.com")

    TOKEN=$(python - <<'PY'
import json,sys
print(json.load(sys.stdin)["value"])
PY
<<< "$TOKEN_JSON")

    echo "$TOKEN" | awk -F'.' '{print $2}' | tr '_-' '/+' | base64 -d 2>/dev/null | jq .

확인해야 할 필드:

  • isshttps://token.actions.githubusercontent.com인지
  • audsts.amazonaws.com인지
  • sub가 Trust Policy의 조건과 정확히 일치하는지

주의:

  • 토큰/클레임 출력은 민감정보가 될 수 있으니, 디버깅 후에는 즉시 제거하거나 로그 마스킹을 고려하세요.

8) “AssumeRole은 성공”인데도 403이면: 권한 정책을 분리해서 점검

AssumeRole이 통과하면 aws sts get-caller-identity는 성공합니다. 그 다음 단계에서 403이 나면 Role permissions policy를 봐야 합니다.

예: ECR 푸시가 목적이라면 최소한 아래 권한이 필요합니다.

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

EKS 배포까지 한다면(예: kubectl/helm) eks:DescribeCluster가 필요하고, 이후 Kubernetes RBAC까지 이어집니다. 이 구간에서 인증/권한이 엉키면 원인 파악이 어려워지므로, 클러스터 권한 이슈는 별도로 분리해 접근하는 게 좋습니다. 관련해서 운영 중 타임아웃/네트워크성 오류 진단은 EKS TLS handshake timeout 원인·해결 9가지도 참고할 만합니다.

9) 재발 방지 체크리스트(운영 관점)

Trust Policy 최소 권한 원칙

  • sub는 가능한 한 좁게(특정 repo + 특정 브랜치/환경)
  • 필요할 때만 StringLike로 확장

워크플로 가드

  • permissions: id-token: write 명시
  • 배포 job과 테스트 job의 권한을 분리(배포 job에만 OIDC 부여)

관측 가능성

  • 실패 시 aws sts get-caller-identity를 먼저 찍어 “AssumeRole 성공/실패”를 즉시 구분
  • CloudTrail에서 AssumeRoleWithWebIdentity 이벤트 확인(누가/어떤 조건으로 거절됐는지 힌트)

10) 결론: 403은 대부분 ‘클레임-조건 불일치’다

GitHub Actions OIDC의 AssumeRole 403은 대개 OIDC Provider(issuer) ARN, audience, subject(sub) 패턴 중 하나가 어긋나서 발생합니다. 가장 빠른 해결 루트는:

  1. 워크플로에 id-token: write가 있는지 확인
  2. AWS 계정에 GitHub OIDC Provider가 존재하는지 확인
  3. 실제 토큰의 sub/aud를 덤프해서 Trust Policy 조건과 1:1로 맞추기

여기까지 맞추면 403의 대부분은 사라지고, 남는 문제는 서비스 권한(ECR/EKS/S3 등) 정책으로 좁혀집니다. 이후 EKS까지 배포가 이어진다면, 업그레이드/권한 매핑 이슈는 Terraform로 EKS 업그레이드 후 aws-auth 꼬임으로 노드 Join 실패 해결처럼 “AWS IAM → Kubernetes 인증/인가 경계”에서 다시 터질 수 있으니 단계별로 분리해 점검하는 것을 권합니다.