Published on

GitHub Actions OIDC로 AWS 배포 AccessDenied 해결

Authors

서버리스든 ECS/EKS든, GitHub Actions에서 aws-actions/configure-aws-credentials로 OIDC 연동을 해두면 “장기 Access Key 없이” 배포가 가능해집니다. 하지만 실제로는 배포 단계에서 AccessDenied가 자주 터집니다. 문제는 대부분 (1) OIDC 신뢰 정책(Trust Policy) 조건 불일치, (2) 권한 정책(IAM Policy) 누락, (3) 세션/리전/리소스 ARN 불일치 중 하나입니다.

이 글에서는 AccessDenied를 “감”이 아니라 로그와 정책을 기준으로 빠르게 분해해서 해결하는 방법을 정리합니다.

> 인증/인가 문제를 체계적으로 점검하는 관점은 아래 글의 체크리스트 접근이 유사합니다: OpenAI Responses API 401 403 인증오류 점검 가이드

1) AccessDenied를 먼저 분류하자: 어디에서 막혔나

OIDC 기반 배포에서 AccessDenied는 크게 두 군데에서 발생합니다.

A. AssumeRoleWithWebIdentity 단계에서 실패

대표 메시지:

  • Not authorized to perform sts:AssumeRoleWithWebIdentity
  • AccessDenied: Invalid identity token (또는 audience/issuer 관련)
  • The role ... cannot be assumed by ... 형태

이 경우는 Trust Policy(신뢰 정책) 또는 OIDC Provider 설정이 문제일 확률이 큽니다.

B. AssumeRole은 성공했는데 AWS API 호출에서 실패

대표 메시지:

  • AccessDenied: User: arn:aws:sts::...:assumed-role/<role>/<session> is not authorized to perform: s3:PutObject on resource ...
  • AccessDeniedException (ECR, CloudFormation, Lambda, ECS 등 서비스별)

이 경우는 해당 역할(Role)에 붙은 권한 정책이 부족하거나, 리소스 ARN/리전/계정이 다를 가능성이 큽니다.

2) GitHub Actions OIDC 기본 구성 확인(워크플로)

가장 먼저 워크플로에 OIDC 발급 권한이 있는지 확인합니다.

name: deploy
on:
  push:
    branches: ["main"]

permissions:
  id-token: write   # OIDC 토큰 발급 필수
  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: write가 없으면 OIDC 토큰 자체가 발급되지 않아, configure 단계에서 실패하거나 이상한 에러가 납니다.

3) IAM OIDC Provider 설정: issuer/audience가 맞는지

AWS IAM에 GitHub OIDC Provider가 등록되어 있어야 합니다.

  • Provider URL: https://token.actions.githubusercontent.com
  • Audience(클라이언트 ID): 보통 sts.amazonaws.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

ClientIDListsts.amazonaws.com가 없다면, Trust Policy의 aud 조건과 충돌할 수 있습니다.

4) Trust Policy(신뢰 정책)에서 가장 많이 틀리는 지점 5가지

AssumeRoleWithWebIdentity가 안 되면, 아래를 순서대로 의심하면 됩니다.

4.1 Principal Federated ARN이 계정/리전/문자열까지 정확한가

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:ORG/REPO:ref:refs/heads/main"
        }
      }
    }
  ]
}
  • Federated ARN의 계정 ID가 다르면 100% 실패합니다.
  • provider 문자열은 정확히 token.actions.githubusercontent.com 이어야 합니다.

4.2 sub 조건이 브랜치/태그/PR 이벤트와 안 맞는다

sub는 이벤트에 따라 달라집니다.

  • push to main: repo:ORG/REPO:ref:refs/heads/main
  • tag: repo:ORG/REPO:ref:refs/tags/v1.2.3
  • PR: repo:ORG/REPO:pull_request (또는 환경/워크플로 설정에 따라 달라질 수 있음)

따라서 배포가 태그 기반이라면 refs/heads/main으로 고정해두면 AccessDenied가 납니다.

태그/브랜치 모두 허용하려면:

"StringLike": {
  "token.actions.githubusercontent.com:sub": [
    "repo:ORG/REPO:ref:refs/heads/main",
    "repo:ORG/REPO:ref:refs/tags/*"
  ]
}

4.3 환경(Environment) 보호 규칙을 쓰면 sub가 달라질 수 있다

GitHub Environments를 쓰면 subenvironment:<name> 컨텍스트를 포함하는 형태로 바뀌는 케이스가 있습니다. 이때는 GitHub 문서 기준 claim을 확인하고, 조건을 그에 맞게 조정해야 합니다.

실전 팁: 너무 빡빡한 sub 조건은 운영 중 이벤트가 늘어날수록 장애를 만듭니다. 대신 repository/ref/workflow 등 다른 claim을 조합해 “의도한 범위”만 허용하는 방식이 안전합니다.

4.4 aud 조건 누락/불일치

대부분 aud=sts.amazonaws.com를 씁니다. Provider에 등록된 audience와 Trust Policy의 audience가 다르면 AssumeRole이 막힙니다.

4.5 조직/리포지토리 대소문자, 포크 여부

ORG/REPO 대소문자나 실제 소유자가 다르면 sub가 불일치합니다. 또한 포크 PR에서 배포를 막는 구성이 일반적인데, 이를 허용하려다 조건이 꼬이기도 합니다.

5) AssumeRole은 됐는데 배포에서 AccessDenied: 권한 정책을 “행동 단위”로 붙이기

aws sts get-caller-identity가 성공했다면 OIDC 연동은 된 겁니다. 이제는 역할에 붙은 권한 정책 문제입니다.

이 단계에서 중요한 건 “관리자 권한을 주면 된다”가 아니라, 배포가 실제로 호출하는 API를 기준으로 최소 권한을 구성하는 것입니다.

5.1 CloudFormation 배포 예시 정책

CloudFormation으로 스택 배포 시 흔히 필요한 권한:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "cloudformation:CreateStack",
        "cloudformation:UpdateStack",
        "cloudformation:DeleteStack",
        "cloudformation:Describe*",
        "cloudformation:Get*",
        "cloudformation:List*"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "s3:PutObject",
        "s3:GetObject",
        "s3:ListBucket"
      ],
      "Resource": [
        "arn:aws:s3:::my-artifact-bucket",
        "arn:aws:s3:::my-artifact-bucket/*"
      ]
    },
    {
      "Effect": "Allow",
      "Action": [
        "iam:PassRole"
      ],
      "Resource": "arn:aws:iam::123456789012:role/cfn-execution-role"
    }
  ]
}

iam:PassRole이 빠지면 CloudFormation이 실행 역할을 넘겨받지 못해 AccessDenied가 납니다.

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

컨테이너 이미지를 ECR에 푸시하는 배포라면 다음 권한이 필요합니다.

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

특히 ecr:GetAuthorizationTokenResource: *가 일반적입니다. 이를 특정 리포지토리로 제한하려다 실패하는 경우가 많습니다.

EKS/쿠버네티스와 엮이면 인증/권한이 더 복잡해지는데, 이미지 풀/푸시와 권한 경계가 헷갈릴 때는 이 글도 도움이 됩니다: Kubernetes ImagePullBackOff 401 - ECR·IRSA·imagePullSecrets

6) 디버깅 루틴: CloudTrail로 “거부된 액션”을 1분 안에 찾기

AccessDenied를 가장 빨리 끝내는 방법은 CloudTrail에서 Deny 이벤트를 보는 것입니다.

  1. CloudTrail Event history에서 시간대 필터
  2. Event name에 실패한 액션(예: PutObject, AssumeRoleWithWebIdentity) 검색
  3. 이벤트 상세의 errorCode, errorMessage, userIdentity.arn, requestParameters 확인

여기서 확인해야 할 핵심:

  • 누가 호출했나? (assumed-role ARN)
  • 정확히 어떤 액션이 거부됐나?
  • 어떤 리소스 ARN에 대해 거부됐나?

이 3개가 나오면 정책 수정은 거의 기계적으로 됩니다.

7) 흔한 실수 모음(체크리스트)

7.1 리전 불일치

워크플로에서 aws-regionus-east-1로 두고, 실제 리소스는 ap-northeast-2에 있으면 서비스에 따라 “리소스 없음” 또는 AccessDenied처럼 보이는 에러가 날 수 있습니다. 특히 ECR, KMS, CloudWatch Logs에서 혼동이 잦습니다.

7.2 S3 버킷 정책이 역할을 거부

역할에 s3:PutObject를 줬는데도 AccessDenied면, 버킷 정책에서 Principal을 제한하고 있을 수 있습니다.

버킷 정책에서 aws:PrincipalArn 또는 특정 VPC endpoint 조건을 걸어두었다면 GitHub Actions 러너에서는 조건이 맞지 않습니다.

7.3 KMS 암호화(SSE-KMS)로 인한 PutObject AccessDenied

S3 업로드가 SSE-KMS를 쓰면, S3 권한 외에 KMS 권한이 필요합니다.

  • kms:Encrypt, kms:Decrypt, kms:GenerateDataKey
  • KMS Key policy에도 해당 Role principal 허용 필요

7.4 Permissions boundary / SCP에 의한 상위 차단

조직(Organizations)에서 SCP로 sts:AssumeRoleWithWebIdentity나 특정 서비스 액션을 막아두면, IAM 정책을 아무리 수정해도 계속 AccessDenied가 납니다.

이 경우 CloudTrail에 “명시적 거부(Explicit deny)” 힌트가 남는 경우가 많습니다.

8) 재현 가능한 최소 구성 예시(권장 베이스라인)

마지막으로, “일단 동작하는” 베이스라인을 만들고 점진적으로 조이는 전략이 안전합니다.

  1. Trust Policy는 aud 고정 + sub는 배포 브랜치/태그만 허용
  2. 권한 정책은 배포 방식(예: ECR+ECS, S3+CloudFront, Lambda, CloudFormation)에 맞춰 액션을 추가
  3. CloudTrail로 거부된 액션이 나올 때만 최소 단위로 확장

GitHub Actions 워크플로(요약):

permissions:
  id-token: write
  contents: read

steps:
  - uses: actions/checkout@v4
  - uses: aws-actions/configure-aws-credentials@v4
    with:
      role-to-assume: arn:aws:iam::123456789012:role/gha-deploy-role
      aws-region: ap-northeast-2
  - run: |
      aws sts get-caller-identity
      # 배포 커맨드 (예: aws s3 sync / aws cloudformation deploy / docker push 등)

9) 마무리: OIDC AccessDenied는 “조건 불일치”와 “정책 누락”의 합성이다

GitHub Actions OIDC에서 AccessDenied는 대개 복잡해 보이지만, 사실상 다음 두 질문으로 수렴합니다.

  • 이 역할을 Assume 할 자격이 있는가? (OIDC Provider + Trust Policy 조건)
  • Assume 후에 필요한 액션 권한이 있는가? (IAM Policy + 리소스 정책(S3/KMS 등) + 조직 정책(SCP))

가장 빠른 해결 루틴은 get-caller-identity로 경계를 나누고, CloudTrail로 “거부된 액션/리소스”를 정확히 찾은 뒤, Trust Policy 또는 권한 정책을 최소 변경으로 맞추는 것입니다.

EKS/쿠버네티스 배포까지 확장하다가 인증/권한이 꼬이는 케이스도 흔한데, 클러스터 권한 맵핑 문제는 다음 글의 문제 해결 흐름이 참고가 됩니다: Terraform로 EKS 업그레이드 후 aws-auth 꼬임으로 노드 Join 실패 해결