Published on

GitHub Actions OIDC로 AWS 배포 403 해결 가이드

Authors

서버리스든(ECR/ECS/Lambda) 인프라든, GitHub Actions에서 OIDC(OpenID Connect) 로 AWS에 배포하도록 전환하면 장기 액세스 키 없이도 안전하게 배포할 수 있습니다. 하지만 설정이 조금만 어긋나도 배포 단계에서 갑자기 403(AccessDenied) 를 만나기 쉽습니다. 특히 AssumeRoleWithWebIdentity 단계에서 막히거나, AssumeRole은 성공했는데 S3/ECR/CloudFormation 호출에서 403이 터지는 케이스가 많습니다.

이 글은 “GitHub Actions OIDC로 AWS 배포 시 403이 나는 이유”를 딱 3층(연동/신뢰/권한) 으로 나눠서, 로그로 확인하고 바로 고칠 수 있게 정리합니다.

> 참고로, 런타임에서 자격 증명 자체가 안 잡히는 문제는 403이 아니라 NoCredentialProviders로 보이는 경우가 많습니다. 해당 유형은 EKS에서 AWS SDK NoCredentialProviders 해결 가이드도 함께 보면 원인 분리가 빨라집니다.

1) 403의 종류부터 구분하기: 어디서 막히는가?

OIDC 기반 배포에서 403은 크게 두 종류입니다.

1.1 AssumeRoleWithWebIdentity 자체가 403

대개 다음 중 하나입니다.

  • IAM Role의 Trust Policy(신뢰 정책)에서 OIDC Provider/조건이 불일치
  • GitHub 토큰의 aud, sub 클레임 조건이 맞지 않음
  • OIDC Provider가 잘못 등록(issuer URL/Thumbprint/ClientIdList)

이때 AWS CLI/SDK 에러는 보통 아래처럼 나옵니다.

An error occurred (AccessDenied) when calling the AssumeRoleWithWebIdentity operation:
Not authorized to perform sts:AssumeRoleWithWebIdentity

1.2 AssumeRole은 성공했는데 배포 API 호출이 403

즉, STS는 통과했지만 Role Permission Policy가 부족한 경우입니다.

  • ECR push에서 ecr:PutImage/ecr:InitiateLayerUpload 누락
  • S3 업로드에서 s3:PutObject는 있는데 버킷 정책이 거부
  • CloudFormation에서 cloudformation:CreateChangeSet 등 누락
  • KMS 암호화(S3 SSE-KMS, ECR KMS 등)로 인해 kms:Encrypt/Decrypt 누락

이때는 대개 이런 식입니다.

AccessDeniedException: User: arn:aws:sts::123:assumed-role/GitHubActionsRole/... is not authorized to perform: ecr:PutImage

2) GitHub Actions OIDC 동작 원리(디버깅 포인트)

OIDC 플로우는 간단합니다.

  1. GitHub Actions Runner가 GitHub OIDC에서 ID Token(JWT) 을 발급받음
  2. AWS STS가 그 토큰을 검증하고, Role의 Trust Policy 조건을 만족하면
  3. 임시 자격 증명(AccessKey/SecretKey/SessionToken)을 발급

여기서 403이 가장 많이 나는 지점은 Trust Policy 조건입니다. 특히 sub(subject) 조건이 Repository/Branch/Environment와 정확히 일치해야 합니다.

3) 가장 흔한 원인 1: Trust Policy의 sub 조건 불일치

GitHub OIDC의 sub는 상황에 따라 형태가 달라집니다.

  • 브랜치 기반: repo:ORG/REPO:ref:refs/heads/main
  • 태그 기반: repo:ORG/REPO:ref:refs/tags/v1.2.3
  • 환경(Environment) 기반: repo:ORG/REPO:environment:prod

즉, Trust Policy에서 submain으로 고정해두고 태그로 배포하면 403이 납니다.

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

체크 포인트

  • aud는 대부분 sts.amazonaws.com이어야 합니다.
  • StringLike를 써서 브랜치/태그/환경을 여러 개 허용할 수 있습니다.
  • 오타(대소문자 포함) 하나만 있어도 STS 단계에서 403이 납니다.

4) 가장 흔한 원인 2: Workflow에 id-token: write 권한 누락

GitHub Actions에서 OIDC 토큰을 발급받으려면 workflow 권한이 필요합니다.

permissions:
  id-token: write
  contents: read

이게 없으면 configure-aws-credentials가 토큰을 못 받아서 다른 형태의 실패가 나거나, 결과적으로 STS 호출이 실패합니다(로그를 보면 OIDC 토큰 관련 메시지가 보입니다).

5) 가장 흔한 원인 3: OIDC Provider 등록/ClientIdList 문제

AWS IAM에 OIDC Provider가 다음 값으로 등록되어 있어야 합니다.

  • Provider URL: https://token.actions.githubusercontent.com
  • Audience(Client ID): 보통 sts.amazonaws.com

콘솔에서 OIDC Provider의 ClientIdListsts.amazonaws.com이 빠져 있으면, aud 조건과 맞지 않아 403이 발생할 수 있습니다.

6) 토큰 클레임을 직접 확인해서 403을 끝내기

가장 확실한 방법은 실제 발급된 JWT의 클레임을 보고 Trust Policy 조건과 비교하는 것입니다.

6.1 GitHub Actions에서 OIDC 토큰 받아 디코딩하기

아래는 디버깅용 예시입니다(운영에서는 토큰 출력/로그 노출 주의).

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - name: Print OIDC token claims (debug)
        shell: bash
        run: |
          set -euo pipefail
          echo "Requesting OIDC token..."
          TOKEN_JSON=$(curl -sSf -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
            "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com")
          TOKEN=$(echo "$TOKEN_JSON" | jq -r '.value')
          echo "$TOKEN" | awk -F. '{print $2}' | tr '_-' '/+' | base64 -d 2>/dev/null | jq

여기서 특히 확인할 값:

  • sub
  • aud
  • repository, ref, environment(있다면)

이 값을 Trust Policy의 StringLike/StringEquals와 맞추면 STS 403은 거의 끝납니다.

7) AssumeRole은 되는데 403: 권한 정책(permissions policy) 점검

STS가 성공했다면 이제는 “배포에 필요한 API 권한이 충분한가” 문제입니다.

7.1 ECR 푸시 403에서 자주 빠지는 권한

최소 권한 예시(리포지토리 ARN은 환경에 맞게 제한 권장):

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "ecr:GetAuthorizationToken",
        "ecr:BatchCheckLayerAvailability",
        "ecr:InitiateLayerUpload",
        "ecr:UploadLayerPart",
        "ecr:CompleteLayerUpload",
        "ecr:PutImage"
      ],
      "Resource": "*"
    }
  ]
}
  • ecr:GetAuthorizationToken은 종종 Resource: *가 필요합니다.
  • 리포지토리 정책(Resource policy)로도 거부될 수 있으니, 조직/계정 간 접근이면 리포지토리 정책도 같이 확인하세요.

7.2 S3 업로드 403: IAM은 허용인데 버킷 정책이 거부

S3는 IAM 정책 + 버킷 정책 + (있다면) VPC Endpoint 정책이 합쳐져 최종 결정됩니다.

  • IAM에 s3:PutObject가 있어도
  • 버킷 정책에 Deny가 있거나
  • 특정 조건(예: aws:PrincipalArn, s3:x-amz-server-side-encryption)을 강제하면

403이 납니다.

특히 EKS/프라이빗 네트워크에서 VPC Endpoint를 쓰고 있다면, 엔드포인트 정책 때문에 “DNS는 되는데 S3만 이상” 같은 현상이 나기도 합니다. 이 케이스는 EKS Pod DNS는 되는데 S3만 503? 엔드포인트 정책과 함께 보면, 정책 계층을 놓치지 않고 추적할 수 있습니다.

7.3 KMS가 숨어있는 403

S3 SSE-KMS, ECR 암호화, Parameter Store SecureString 등을 쓰면 KMS 권한이 필요합니다.

  • kms:Encrypt, kms:Decrypt, kms:GenerateDataKey
  • 그리고 KMS Key policy에서 Role을 신뢰해야 함(IAM만으로는 부족할 수 있음)

증상은 대개:

AccessDeniedException: not authorized to perform kms:Decrypt on key ...

8) GitHub Actions 배포 워크플로 예시(정상 구성)

아래는 OIDC로 Role을 받아 ECR 로그인 후 이미지를 푸시하는 흐름 예시입니다.

name: deploy

on:
  push:
    branches: ["main"]

permissions:
  id-token: write
  contents: read

jobs:
  build-and-push:
    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/GitHubActionsRole
          aws-region: ap-northeast-2

      - name: Login to ECR
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build and push
        env:
          ECR_REGISTRY: 123456789012.dkr.ecr.ap-northeast-2.amazonaws.com
          ECR_REPOSITORY: my-app
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG

이 구성이 403을 피하는 핵심은:

  • permissions.id-token: write
  • Trust Policy에서 aud=sub 조건이 실제 토큰과 일치
  • ECR에 필요한 액션이 Role policy에 포함

9) 빠른 체크리스트: 403을 10분 안에 좁히는 순서

9.1 STS 단계 403이면

  1. Workflow에 id-token: write가 있는지
  2. IAM OIDC Provider가 token.actions.githubusercontent.com인지
  3. Provider ClientIdList에 sts.amazonaws.com가 있는지
  4. Role Trust Policy의
    • Federated ARN이 올바른지
    • audsts.amazonaws.com인지
    • sub가 실제 토큰과 일치하는지(브랜치/태그/환경)

9.2 API 호출 단계 403이면

  1. 에러 메시지에 찍힌 Action(예: ecr:PutImage)을 Role policy에 추가
  2. 리소스 정책(S3 버킷 정책, ECR repo policy, KMS key policy)의 Deny 확인
  3. 조건 강제(암호화 헤더, aws:PrincipalArn, aws:SourceVpce)로 인한 거부 확인

네트워크/STS 엔드포인트 접근 문제는 보통 403이 아니라 타임아웃/5xx로 보이지만, 환경에 따라 혼동되기도 합니다. STS 호출이 비정상적으로 실패한다면 EKS Pod STS AssumeRole 타임아웃 - NAT·PrivateLink·DNS처럼 연결 계층도 함께 점검해 두면 좋습니다.

10) 결론: 403은 “신뢰(Trust) vs 권한(Permission)”으로 쪼개면 끝난다

GitHub Actions OIDC에서 403을 해결하는 가장 좋은 방법은, 문제를 감으로 고치지 않고 아래처럼 분리하는 것입니다.

  • AssumeRoleWithWebIdentity가 403이면: Trust Policy/Provider/클레임(sub, aud) 문제
  • AssumeRole 이후가 403이면: Role policy + 리소스 정책(S3/ECR/KMS) + 조건 문제

특히 sub는 배포 트리거(브랜치/태그/환경)에 따라 달라지므로, 한 번은 토큰 클레임을 직접 디코딩해서 “내가 허용한 조건과 실제 값이 같은지”를 확인하는 게 가장 빠른 정답입니다.