Published on

GitHub Actions OIDC AWS 배포 AccessDenied 해결

Authors

서론

GitHub Actions에서 AWS Access Key 없이 배포하려고 OIDC(OpenID Connect) 기반 AssumeRoleWithWebIdentity를 붙이면, 설정이 조금만 어긋나도 바로 AccessDenied를 만나게 됩니다. 문제는 AccessDenied원인이 매우 다양하다는 점입니다.

  • OIDC Provider(issuer/클라이언트 ID) 불일치
  • IAM Role의 신뢰 정책(Trust policy) 조건이 GitHub 토큰 클레임과 불일치
  • Role은 잘 Assume 됐지만, 실제 배포 작업에 필요한 권한 정책(permissions) 부족
  • aud, sub, ref, environment 등 조건을 너무 타이트하게 걸어 특정 브랜치/태그/환경만 실패

이 글은 “OIDC로 AssumeRole은 하려는데 AccessDenied가 난다”를 기준으로, 어디에서 거절되는지를 먼저 판별하고(핵심), 그 다음에 자주 틀리는 신뢰 정책/권한 정책을 실제 예시로 정리합니다. OIDC 기본 구성은 아래 글을 먼저 보고 오면 더 빠릅니다.

1) AccessDenied를 “어느 단계에서” 맞는지부터 분리

OIDC 배포에서 AccessDenied는 크게 두 갈래입니다.

  1. AssumeRoleWithWebIdentity 자체가 거절됨 (신뢰 정책/조건/OIDC Provider 문제)
  2. AssumeRole은 성공했는데, AWS API 호출이 거절됨 (권한 정책 문제)

로그에서 다음 문구가 보이면 1번 가능성이 큽니다.

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

반대로, 아래처럼 특정 서비스 액션이 거절되면 2번입니다.

An error occurred (AccessDenied) when calling the CreateInvalidation operation:
User: arn:aws:sts::123456789012:assumed-role/GHADeployRole/... is not authorized to perform: cloudfront:CreateInvalidation

이 분기만 제대로 하면 문제 해결 속도가 3배는 빨라집니다.

2) GitHub Actions 워크플로우 기본 체크 (권한/토큰)

OIDC 토큰을 발급받으려면 워크플로우에 id-token: write 권한이 필요합니다. 이게 없으면 보통 InvalidIdentityToken이나 토큰 발급 실패로 가지만, 상황에 따라 AccessDenied처럼 보일 때도 있어 먼저 확인합니다.

name: deploy

on:
  push:
    branches: ["main"]

permissions:
  contents: read
  id-token: write

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

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/GHADeployRole
          aws-region: ap-northeast-2

      - run: aws sts get-caller-identity

aws sts get-caller-identity가 성공하면 AssumeRole은 된 것이고, 이후 실패는 권한 정책 쪽으로 보면 됩니다.

3) (1번 케이스) AssumeRoleWithWebIdentity가 AccessDenied인 경우

3-1) IAM Role의 Trust policy에서 가장 많이 틀리는 지점

OIDC는 “이 Role을 누가 Assume할 수 있는가”를 Trust policy로 제어합니다. GitHub Actions의 issuer는 token.actions.githubusercontent.com이며, 조건은 보통 audsub를 씁니다.

아래는 가장 흔한 형태의 예시입니다.

{
  "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:*"
        }
      }
    }
  ]
}

여기서 AccessDenied로 이어지는 대표 실수:

  • Principal.Federated ARN이 다른 계정이거나 오타
  • audsts.amazonaws.com이 아닌 값으로 제한
  • sub를 너무 구체적으로 제한해서 특정 브랜치/태그만 매칭 실패

sub는 보통 다음 패턴입니다.

  • 브랜치 push: repo:{org}/{repo}:ref:refs/heads/{branch}
  • 태그: repo:{org}/{repo}:ref:refs/tags/{tag}
  • environment 사용 시: repo:{org}/{repo}:environment:{envName}

따라서 “main 브랜치만 허용”하고 싶으면 이렇게 씁니다.

"StringLike": {
  "token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:ref:refs/heads/main"
}

태그 배포도 허용하려면 두 패턴을 OR로 풀어야 합니다(Statement를 두 개로 나누거나, 와일드카드 전략을 씁니다).

3-2) GitHub Environment를 쓰는데 sub 조건이 ref 기반이면 실패

예를 들어 워크플로우에 environment: production을 지정하면, GitHub가 발급하는 토큰의 subenvironment: 형태로 나올 수 있습니다. 그런데 Trust policy가 ref:만 허용하면 Assume 단계에서 AccessDenied가 납니다.

해결은 Trust policy에 environment 패턴을 추가합니다.

{
  "StringLike": {
    "token.actions.githubusercontent.com:sub": [
      "repo:my-org/my-repo:ref:refs/heads/main",
      "repo:my-org/my-repo:environment:production"
    ]
  }
}

3-3) OIDC Provider 설정(클라이언트 ID/aud)이 꼬인 경우

IAM의 OIDC Provider에서 Client ID(audience)가 sts.amazonaws.com로 등록되어 있어야 일반적인 구성과 맞습니다. Provider에 다른 값만 등록되어 있거나, Trust policy에서 aud를 강제했는데 실제 토큰 aud와 다르면 Assume이 거절됩니다.

실무에서는 aud 조건은 유지하되(보안상 의미 있음), Provider와 워크플로우 액션이 기본값(sts.amazonaws.com)을 쓰도록 맞추는 편이 안전합니다.

4) (2번 케이스) AssumeRole은 성공했는데 AWS API가 AccessDenied인 경우

get-caller-identity는 성공하는데, S3 업로드/CloudFront invalidation/ECR push/EKS 배포에서 AccessDenied가 뜬다면 Role에 붙은 권한 정책이 부족합니다.

4-1) 가장 흔한 S3 배포 AccessDenied: 버킷 정책 vs IAM 정책

정적 사이트 배포에서 자주 보는 실패:

An error occurred (AccessDenied) when calling the PutObject operation: Access Denied

이 경우 체크 순서:

  1. Role에 s3:PutObject 권한이 있는가
  2. 대상 버킷 ARN/오브젝트 ARN이 정책 리소스와 일치하는가
  3. 버킷 정책에서 해당 Role을 명시적으로 거부(Deny)하거나, 다른 조건을 강제하는가
  4. KMS로 암호화된 버킷이면 kms:Encrypt/Decrypt가 필요한가

최소 예시(IAM permissions policy):

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

리소스를 arn:aws:s3:::my-deploy-bucket만 넣고 /*를 빼면 PutObject는 계속 AccessDenied가 납니다.

4-2) CloudFront invalidation AccessDenied

배포 후 캐시 무효화에서 흔한 오류:

is not authorized to perform: cloudfront:CreateInvalidation on resource: arn:aws:cloudfront::123456789012:distribution/EDFDVBD6EXAMPLE

권한 추가:

{
  "Effect": "Allow",
  "Action": ["cloudfront:CreateInvalidation"],
  "Resource": "arn:aws:cloudfront::123456789012:distribution/EDFDVBD6EXAMPLE"
}

CloudFront는 리소스 ARN이 계정/배포 ID까지 정확히 들어가야 합니다.

4-3) ECR push AccessDenied (토큰은 받았는데 push가 안 됨)

ECR은 ecr:GetAuthorizationToken(리소스 *)과 리포지토리 단위 권한이 함께 필요합니다.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["ecr:GetAuthorizationToken"],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "ecr:BatchCheckLayerAvailability",
        "ecr:InitiateLayerUpload",
        "ecr:UploadLayerPart",
        "ecr:CompleteLayerUpload",
        "ecr:PutImage"
      ],
      "Resource": "arn:aws:ecr:ap-northeast-2:123456789012:repository/my-app"
    }
  ]
}

여기서 GetAuthorizationToken을 리포지토리 ARN으로 제한하면 실패합니다(반드시 *).

5) AccessDenied를 “증거 기반”으로 좁히는 방법 (CloudTrail)

가장 빠른 디버깅은 CloudTrail에서 이벤트를 직접 보는 것입니다.

  • Assume 단계 실패: AssumeRoleWithWebIdentity 이벤트가 errorCode=AccessDenied로 남음
  • API 호출 실패: 예) PutObject, CreateInvalidation, eks:DescribeCluster 등이 AccessDenied로 남음

CloudTrail 이벤트에서 특히 유용한 필드:

  • eventSource (sts.amazonaws.com, s3.amazonaws.com 등)
  • eventName
  • userIdentity.arn (assumed-role인지, 어떤 세션인지)
  • errorMessage (조건 불일치/명시적 Deny 등 힌트)

조직에서 EKS 배포까지 엮여 있다면, 권한 문제로 보이지만 실제로는 클러스터/서비스 구성이 꼬여 장애처럼 보이는 경우도 많습니다. EKS 운영 이슈 트러블슈팅은 아래 글도 참고할 만합니다.

6) 실전 체크리스트 (가장 많이 막히는 10분 코스)

  1. 워크플로우에 permissions: id-token: write 있는가
  2. aws sts get-caller-identity가 성공하는가
    • 실패면 Trust policy/OIDC Provider/조건 문제(3장)
    • 성공이면 권한 정책 문제(4장)
  3. Trust policy의 sub가 실제 배포 트리거(브랜치/태그/environment)와 맞는가
  4. Trust policy의 audsts.amazonaws.com로 맞는가
  5. IAM permissions policy에서 리소스 ARN이 정확한가(S3는 /*, CloudFront는 distribution ARN)
  6. 버킷 정책/KMS 키 정책/Organizations SCP에서 명시적 Deny가 없는가
  7. CloudTrail에서 eventNameerrorMessage로 최종 거절 지점을 확인했는가

결론

GitHub Actions OIDC로 AWS 배포를 구성했을 때의 AccessDenied는 “OIDC가 안 된다”가 아니라, 대개 신뢰 정책 조건 불일치(Assume 단계) 또는 권한 정책 누락(API 단계) 중 하나입니다. 먼저 get-caller-identity로 경계를 나눈 뒤, Trust policy의 sub/aud와 실제 GitHub 토큰 클레임(브랜치/태그/environment)을 맞추고, 마지막으로 배포 대상 서비스(S3/CloudFront/ECR/EKS)에 필요한 최소 권한을 리소스 단위로 정확히 부여하면 대부분 깔끔하게 해결됩니다.