Published on

GitHub Actions OIDC로 AWS 배포 - AssumeRole 403 해결

Authors

서버리스든 ECS/EKS든, GitHub Actions에서 AWS로 배포할 때 장기 Access Key를 없애기 위해 OIDC(웹 아이덴티티)AssumeRoleWithWebIdentity를 붙이는 구성이 사실상 표준이 됐습니다. 문제는 설정이 조금만 어긋나도 배포 단계에서 바로 403 AccessDenied가 터진다는 점입니다. 특히 에러 메시지가 짧게 나오면(예: Not authorized to perform sts:AssumeRoleWithWebIdentity) 어디가 틀렸는지 감이 안 옵니다.

이 글에서는 GitHub Actions OIDC로 AWS 배포 시 발생하는 AssumeRole 403을 원인별로 분해하고, 가장 재현률 높은 실수(OIDC Provider/신뢰 정책 조건/권한 정책/리포지토리·브랜치 매칭/대상 Role ARN 오타)를 체크리스트 형태로 해결합니다.

> 참고로, “403”이라는 증상은 AWS뿐 아니라 경계면(인증/인가/프록시/WAF)에서 자주 반복됩니다. EKS에서 403을 로그로 푸는 접근은 EKS ALB Ingress WAF 403 차단 로그로 푸는 법도 같이 보면 디버깅 사고방식이 도움이 됩니다.

1) 전제: GitHub Actions OIDC 흐름 요약

GitHub Actions는 실행 중에 OIDC 토큰(JWT)을 발급받을 수 있고, AWS STS는 이 토큰을 검증해 임시 자격 증명(AccessKeyId/SecretAccessKey/SessionToken) 을 발급합니다.

흐름은 다음과 같습니다.

  1. Workflow가 id-token: write 권한으로 OIDC 토큰을 요청
  2. AWS IAM에 등록된 OIDC Provider(token.actions.githubusercontent.com)가 토큰을 검증
  3. Role의 Trust policy가 토큰 클레임(sub, aud 등)을 조건으로 허용
  4. STS가 AssumeRoleWithWebIdentity를 수행하고 임시 크레덴셜 발급
  5. 이후 AWS API 호출은 해당 Role의 Permissions policy로 결정

403이 나는 지점은 크게 2가지입니다.

  • STS AssumeRole 단계에서 403: Trust policy/OIDC Provider/클레임 조건/Role ARN 문제
  • Assume 성공 후 AWS API 호출에서 403: Role 권한 정책(permissions) 부족

로그에서 에러가 sts:AssumeRoleWithWebIdentity인지, s3:PutObject 같은 서비스 API인지 먼저 구분해야 합니다.

2) 가장 흔한 403 유형 3가지

(A) Not authorized to perform sts:AssumeRoleWithWebIdentity

거의 항상 Trust policy 또는 OIDC Provider 설정 문제입니다.

  • OIDC Provider가 아예 없거나 Thumbprint/URL/Audience가 불일치
  • Trust policy에서 Principal.Federated ARN이 잘못됨
  • Conditionsub가 현재 실행된 ref(브랜치/태그/환경)와 불일치
  • aud 조건을 걸어놓고 실제 토큰 aud가 다름(대개 sts.amazonaws.com)

(B) InvalidIdentityToken / No OpenIDConnect provider found

OIDC Provider 리소스가 없거나, Provider ARN을 잘못 참조했을 때 자주 발생합니다.

  • AWS 계정에 token.actions.githubusercontent.com Provider 미생성
  • 다른 리전에 있는 리소스가 아니라 계정 전역(IAM) 이라서, 단순히 계정이 다름

(C) AssumeRole은 성공했는데 배포가 403

이 경우는 STS는 통과했고, Role 권한 정책이 부족한 것입니다.

예)

  • S3 업로드: s3:PutObject/s3:ListBucket 누락
  • ECR 푸시: ecr:InitiateLayerUpload 등 누락
  • CloudFormation/SSM/Secrets Manager 호출 권한 누락

S3 403의 원인이 다양하듯(버킷 정책, KMS, VPC 엔드포인트 등) AssumeRole 이후의 403도 “권한 정책”만이 원인이 아닐 수 있습니다. S3라면 EKS Pod에서 S3 403 AccessDenied 원인 10가지처럼 리소스 정책/암호화/조건절까지 같이 확인해야 합니다.

3) 필수: GitHub Actions Workflow 권한 설정

OIDC 토큰을 받으려면 Workflow에 권한을 명시해야 합니다.

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: Who am I?
        run: aws sts get-caller-identity

체크 포인트:

  • permissions.id-token: write가 없으면 OIDC 토큰 발급 자체가 안 됩니다.
  • role-to-assume ARN 오타가 생각보다 흔합니다(계정 번호/Role 이름/경로).

4) AWS IAM OIDC Provider 생성(정석 구성)

AWS 콘솔에서 IAM → Identity providers → Add provider

  • Provider type: OpenID Connect
  • Provider URL: https://token.actions.githubusercontent.com
  • Audience: sts.amazonaws.com

CLI로도 만들 수 있습니다(이미 있다면 생략).

aws iam create-open-id-connect-provider \
  --url https://token.actions.githubusercontent.com \
  --client-id-list sts.amazonaws.com \
  --thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1

주의:

  • Thumbprint는 AWS 문서/콘솔에서 자동 처리되는 편이지만, 수동 입력 시 값이 바뀌는 경우가 있어 콘솔 생성이 안전합니다.
  • Provider는 계정 단위입니다. 다른 AWS 계정에 만들고 현재 계정 Role에서 참조하면 403/에러가 납니다.

5) 핵심: Trust policy(신뢰 정책) 올바르게 쓰기

AssumeRole 403의 80%는 여기서 납니다. 아래는 “main 브랜치 push”만 허용하는 최소 예시입니다.

{
  "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:YOUR_ORG/YOUR_REPO:ref:refs/heads/main"
        }
      }
    }
  ]
}

5.1 sub 조건에서 가장 많이 틀리는 포인트

sub는 실행 컨텍스트에 따라 달라집니다.

  • 브랜치 push: repo:org/repo:ref:refs/heads/main
  • 태그 push: repo:org/repo:ref:refs/tags/v1.2.3
  • PR: repo:org/repo:pull_request
  • Environments 사용 시: repo:org/repo:environment:prod 형태가 섞일 수 있음

따라서 운영 배포를 environment: production으로 강제하고 싶다면 sub를 environment 기준으로 제한하는 편이 더 안전합니다.

예: GitHub Environment prod에서만 허용

{
  "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:YOUR_ORG/YOUR_REPO:environment:prod"
    }
  }
}

5.2 브랜치/태그를 여러 개 허용해야 한다면

StringLike로 패턴 매칭을 쓰는 방법이 실무에서 많이 쓰입니다.

{
  "Condition": {
    "StringEquals": {
      "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
    },
    "StringLike": {
      "token.actions.githubusercontent.com:sub": [
        "repo:YOUR_ORG/YOUR_REPO:ref:refs/heads/main",
        "repo:YOUR_ORG/YOUR_REPO:ref:refs/tags/v*"
      ]
    }
  }
}

주의: StringLike를 너무 넓게 풀면(예: repo:org/repo:*) 사실상 누구나 해당 리포에서 Role을 Assume할 수 있게 되어 위험합니다.

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

403이 나면 “내가 생각한 sub/aud가 맞나?”부터 확인해야 합니다. GitHub Actions에서는 OIDC 토큰을 직접 받아서(주의: 로그 노출 위험) 헤더/페이로드를 디코딩해 볼 수 있습니다.

아래는 디버깅 전용 예시입니다. 토큰을 그대로 출력하지 말고 payload만 최소로 확인하세요.

- name: Debug OIDC claims (DO NOT use in production logs)
  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=$(echo "$TOKEN_JSON" | jq -r '.value')

    # Decode JWT payload
    PAYLOAD=$(echo "$TOKEN" | cut -d '.' -f2 | tr '_-' '/+' | base64 -d 2>/dev/null || true)
    echo "$PAYLOAD" | jq '{sub, aud, iss, repository, ref, actor, workflow, job_workflow_ref}'

여기서 출력된 sub 값이 Trust policy의 token.actions.githubusercontent.com:sub와 1글자라도 다르면 STS 단계에서 403이 납니다.

7) AssumeRole 성공/실패를 CloudTrail로 확정하기

로컬 추측으로 시간을 쓰기보다, CloudTrail 이벤트로 STS 호출 결과를 먼저 확정하는 게 빠릅니다.

  • 이벤트 소스: sts.amazonaws.com
  • 이벤트 이름: AssumeRoleWithWebIdentity
  • 에러 코드: AccessDenied인지, 성공인지
  • requestParameters/responseElements에 어떤 Role ARN으로 요청했는지

CloudTrail에서 성공이 확인되면, 이후 403은 서비스 권한/리소스 정책 문제로 범위를 좁힐 수 있습니다.

8) “AssumeRole은 되는데 배포가 403”일 때 권한 정책 예시

예를 들어 S3에 빌드 아티팩트를 업로드하는 배포라면 최소 권한은 보통 아래 정도가 필요합니다.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "UploadArtifacts",
      "Effect": "Allow",
      "Action": [
        "s3:PutObject",
        "s3:AbortMultipartUpload",
        "s3:ListBucket",
        "s3:GetBucketLocation"
      ],
      "Resource": [
        "arn:aws:s3:::my-deploy-bucket",
        "arn:aws:s3:::my-deploy-bucket/*"
      ]
    }
  ]
}

여기서도 403이 계속 난다면 다음을 추가로 봐야 합니다.

  • 버킷 정책에서 해당 Role을 명시적으로 거부(Deny)하고 있지 않은지
  • KMS로 암호화된 버킷이라면 kms:Encrypt 권한 및 KMS 키 정책이 맞는지
  • 조직 SCP(Service Control Policy)로 STS나 특정 서비스 액션이 막히지 않았는지

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

아래 순서대로 보면 대개 빠르게 해결됩니다.

  1. 에러가 STS인지 서비스 API인지 로그로 구분
  2. Workflow에 permissions: id-token: write 존재 확인
  3. role-to-assume ARN이 정확한지(계정/이름/경로)
  4. IAM에 OIDC Provider token.actions.githubusercontent.com 존재 확인
  5. Trust policy의 Principal.Federated가 Provider ARN과 일치하는지
  6. Trust policy의 audsts.amazonaws.com인지
  7. Trust policy의 sub가 실제 실행 컨텍스트와 일치하는지
  8. CloudTrail에서 AssumeRoleWithWebIdentity 이벤트로 성공/실패 확정
  9. Assume 성공이면 Role permissions + 리소스 정책(S3/ECR/KMS 등) 점검
  10. 마지막으로 조직 SCP/Permission boundary 여부 확인

10) 결론: 403을 “조건 불일치”로 생각하면 빨라진다

GitHub Actions OIDC의 AssumeRole 403은 대부분 OIDC 토큰 클레임(sub/aud)IAM Trust policy 조건이 맞지 않아 발생합니다. 먼저 STS 단계에서 막히는지부터 분리하고, 실제 토큰 클레임을 확인한 뒤 Trust policy를 최소 권한으로 조정하면 해결 속도가 확 올라갑니다.

배포 파이프라인에서 403은 종종 네트워크/보안 계층까지 얽혀 증상이 비슷하게 보이기도 합니다. EKS/ALB/WAF 등 다른 계층의 403 디버깅 패턴이 궁금하다면 EKS AWS Load Balancer Controller 설치 후 403 해결도 함께 참고하면 좋습니다.