- Published on
GitHub Actions OIDC로 AWS AssumeRoleAccessDenied 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버리스/컨테이너 배포 파이프라인을 GitHub Actions로 옮기면서 가장 흔하게 마주치는 에러 중 하나가 AssumeRoleAccessDenied입니다. 기존에는 장기 액세스 키를 리포지토리 시크릿에 넣고 aws configure로 해결했지만, 보안/감사 요구가 커지면서 **OIDC(OpenID Connect) 기반의 단기 자격증명(AssumeRoleWithWebIdentity)**가 사실상 표준이 됐습니다.
문제는 OIDC로 전환할 때 AWS IAM 신뢰 정책(trust policy)과 GitHub 토큰 클레임(sub, aud 등)이 조금만 어긋나도, 로그에는 단 한 줄로만 실패가 찍히는 경우가 많다는 점입니다.
이 글은 “왜 거부됐는지”를 재현 가능한 체크리스트로 분해하고, 실제로 동작하는 IAM + GitHub Actions 설정 예제까지 한 번에 정리합니다.
> 참고로, CI/CD에서의 진단 접근은 네트워크/애플리케이션 타임아웃을 파고드는 방식과 유사합니다. 증상을 분해하고(어디서 거부되는지), 관측 가능한 신호(클레임/정책/권한)를 늘린 뒤, 최소 변경으로 원인을 좁혀갑니다. 비슷한 진단 관점은 Spring Boot 3 Feign 타임아웃·재시도 함정 9가지에서도 도움이 됩니다.
AssumeRoleAccessDenied가 의미하는 것
OIDC로 AWS 역할을 Assume할 때 GitHub Actions는 대략 다음 순서로 움직입니다.
- GitHub가 워크플로 실행에 대해 OIDC 토큰(JWT)을 발급한다.
aws-actions/configure-aws-credentials가 그 토큰을 AWS STS에 제출한다.- AWS STS가 IAM Role의 신뢰 정책(누가 Assume할 수 있는가)을 검사한다.
- 통과하면 단기 자격증명(AccessKey/Secret/SessionToken)을 발급한다.
- 이후 AWS API 호출은 그 자격증명으로 진행한다.
따라서 AssumeRoleAccessDenied는 대부분 **3번(신뢰 정책)**에서 막힌 경우입니다. 다만 로그에 따라 다음 두 케이스가 섞여 보일 수 있습니다.
- 신뢰 정책 불일치:
Not authorized to perform sts:AssumeRoleWithWebIdentity - 역할 자체 권한/경계/조직 정책 문제: Assume은 됐지만 이후
AccessDenied가 발생
이 글은 특히 첫 번째(Assume 자체가 거부) 문제를 중심으로 다룹니다.
가장 흔한 원인 7가지 (우선순위 순)
1) GitHub Actions에 OIDC 권한이 없음 (permissions: id-token: write)
OIDC 토큰을 받으려면 워크플로에 다음이 필요합니다.
permissions:
id-token: write
contents: read
이게 없으면 액션이 토큰을 못 받아 STS 호출 자체가 실패하거나, 내부적으로 다른 형태의 오류가 납니다. 특히 조직/리포지토리에서 기본 권한을 read로 잠가둔 경우 자주 놓칩니다.
2) IAM Role 신뢰 정책에서 Principal.Federated가 잘못됨
OIDC 제공자는 아래 ARN 형태여야 합니다.
arn:aws:iam::<ACCOUNT_ID>:oidc-provider/token.actions.githubusercontent.com
간혹 https://token.actions.githubusercontent.com 같이 URL을 넣거나, provider를 다른 계정에 만들어두고 역할은 현재 계정에 만드는 실수가 있습니다.
3) aud(Audience) 조건 불일치
GitHub OIDC 토큰의 기본 aud는 대개 sts.amazonaws.com입니다. 신뢰 정책에서 StringEquals로 token.actions.githubusercontent.com:aud를 제한해두고 값이 다르면 거부됩니다.
4) sub(Subject) 조건 불일치: 브랜치/태그/환경(environment) 구분
가장 많이 틀리는 부분이 sub입니다. sub는 대략 이런 패턴을 가집니다.
- 브랜치 기준:
repo:<OWNER>/<REPO>:ref:refs/heads/<BRANCH> - 태그 기준:
repo:<OWNER>/<REPO>:ref:refs/tags/<TAG> - GitHub Environment 기준(설정에 따라):
repo:<OWNER>/<REPO>:environment:<ENV_NAME>
신뢰 정책을 main 브랜치로 고정해두고, 실제 배포는 release/*에서 해서 거부되는 식의 사고가 잦습니다.
5) 리포지토리/조직 이름 대소문자·오타
repo:Owner/Repo에서 Owner/Repo는 정확히 일치해야 합니다. 특히 조직 리네임, 포크 후 적용, 모노레포 분리 시에 자주 발생합니다.
6) STS 엔드포인트/리전 혼동(특히 Gov/China)
상용 AWS가 아니라 GovCloud/China 리전이면 OIDC 제공자 URL/STS 엔드포인트가 달라질 수 있습니다. 대부분은 상용 AWS에서 발생하므로 이 케이스는 빈도가 낮지만, 해당 환경이라면 먼저 확인하세요.
7) SCP(Organizations) 또는 Permission Boundary로 STS 호출이 차단
신뢰 정책이 맞아도 계정에 SCP가 걸려 sts:AssumeRoleWithWebIdentity를 막으면 동일한 증상이 납니다. 이 경우 CloudTrail에서 거부 주체가 SCP인지 확인해야 합니다.
“정답 설정” 예제: IAM OIDC Provider + Role Trust Policy
1) OIDC Provider 생성(한 번만)
CLI로 생성하는 예시입니다.
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com \
--thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1
client-id-list는 신뢰 정책에서aud제한과 연결됩니다.- thumbprint는 AWS 문서/가이드를 따르되, 조직 정책에 따라 IaC(Terraform/CloudFormation)로 관리하는 것을 권장합니다.
2) Role 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/main",
"repo:my-org/my-repo:ref:refs/heads/release/*"
]
}
}
}
]
}
> 팁: sub를 과하게 넓히면(예: repo:my-org/my-repo:*) 편하긴 하지만, PR/임의 브랜치에서도 배포 역할을 Assume할 수 있는 보안 리스크가 생깁니다. 최소 권한 원칙으로 좁혀두세요.
GitHub Actions 워크플로 예제(동작 보장 형태)
아래 예제는 OIDC로 역할을 Assume한 뒤 S3에 업로드하는 단순 배포 흐름입니다.
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: Verify caller identity
run: aws sts get-caller-identity
- name: Upload artifact
run: aws s3 cp ./dist s3://my-bucket/dist/ --recursive
여기서 실패한다면, 문제는 거의 항상 아래 둘 중 하나입니다.
permissions누락- Role trust policy의
sub/aud불일치
빠른 진단: “sub가 뭐로 들어오는지” 확인하기
신뢰 정책을 맞추려면, 실제 워크플로에서 발급되는 OIDC 토큰의 클레임을 확인하는 게 가장 빠릅니다. GitHub는 ACTIONS_ID_TOKEN_REQUEST_URL과 ACTIONS_ID_TOKEN_REQUEST_TOKEN 환경 변수를 제공하므로, 토큰을 받아 JWT payload를 출력할 수 있습니다.
> 주의: 토큰 원문을 로그에 그대로 출력하면 보안상 위험합니다. 아래는 payload만 최소로 확인하는 예시이며, 실제 운영에서는 필요한 순간에만 사용하고 즉시 제거하세요.
- name: Dump OIDC token claims (debug)
shell: bash
run: |
set -euo pipefail
resp=$(curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com")
token=$(python -c 'import json,sys; print(json.load(sys.stdin)["value"])' <<< "$resp")
payload=$(python - <<'PY'
import os, json, base64
import sys
token = os.environ["TOKEN"]
parts = token.split(".")
payload = parts[1] + "==="
print(base64.urlsafe_b64decode(payload).decode())
PY
)
echo "$payload" | jq '{sub, aud, repository, ref, sha, actor, repository_owner}'
env:
TOKEN: ${{ steps.idtoken.outputs.token }}
위처럼 구현하려면 토큰을 step output으로 빼야 하는데, 일반적으로는 간단히 curl 응답에서 token을 받아 같은 step에서 payload만 디코드하는 방식으로 충분합니다. 핵심은 sub가 기대와 다르게 나오는지를 확인하는 것입니다.
또 다른 실전 팁은 CloudTrail을 보는 것입니다.
- 이벤트 소스:
sts.amazonaws.com - 이벤트 이름:
AssumeRoleWithWebIdentity - 실패 사유/조건 불일치가 좀 더 명확히 남는 경우가 많음
흔한 실수 패턴별 해결 레시피
패턴 A: main에서만 되는데 release 브랜치에서 실패
- 증상:
mainpush는 성공,release/1.2.3는AssumeRoleAccessDenied - 원인: trust policy가
refs/heads/main으로 고정 - 해결:
StringLike로refs/heads/release/*추가
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:ref:refs/heads/release/*"
}
패턴 B: PR에서는 반드시 막고 싶은데, 가끔 PR에서 Assume이 됨
- 원인:
sub를 너무 넓게 열었거나,pull_request이벤트에서도 같은 역할을 사용 - 해결:
- 배포 워크플로는
push(보호 브랜치)에서만 실행 - trust policy에서
ref기반으로 제한 - 필요하면 GitHub Environment + Required reviewers로 사람 승인 추가
- 배포 워크플로는
패턴 C: Assume은 성공했는데 이후 S3/ECR에서 AccessDenied
이건 AssumeRoleAccessDenied가 아니라 역할 정책(permissions policy) 문제입니다.
aws sts get-caller-identity는 성공aws s3 cp또는aws ecr get-login-password에서 거부
해결은 역할에 붙은 정책을 점검하면 됩니다. 예를 들어 ECR 푸시가 필요하면:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ecr:GetAuthorizationToken",
"ecr:BatchCheckLayerAvailability",
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload",
"ecr:PutImage"
],
"Resource": "*"
}
]
}
단, Resource를 *로 두는 대신 특정 리포지토리 ARN으로 좁히는 것이 바람직합니다.
운영 품질을 올리는 권장사항
1) 역할을 “환경별로 분리”하고 sub도 환경에 맞춰 제한
예:
github-actions-deploy-devgithub-actions-deploy-prod
그리고 prod는 GitHub Environment prod에서만 실행되도록 하고, trust policy도 다음처럼 제한합니다.
"StringEquals": {
"token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:environment:prod"
}
이렇게 하면 브랜치 규칙만으로는 막기 어려운 “실수 배포”를 한 번 더 방지할 수 있습니다.
2) 디버그는 짧게, 관측은 구조적으로
토큰 클레임 출력은 강력하지만 민감합니다. 운영에서는 CloudTrail, IAM Access Analyzer, 정책 시뮬레이터를 활용해 로그에 민감정보를 남기지 않고 원인을 좁히는 게 좋습니다. 장애/오류를 다룰 때 “재시도”나 “백오프”로 덮기보다, 먼저 원인 신호를 늘리는 접근이 중요합니다. 이 관점은 OpenAI 429/Rate Limit 대응 - 재시도·백오프·큐잉 같은 글에서 다룬 패턴과도 통합니다.
3) EKS 배포 파이프라인이라면 권한 범위를 더 촘촘히
EKS에 배포하는 경우(예: kubectl, helm)는 IAM Role이 EKS API에 접근하는 것뿐 아니라, 클러스터 내부 RBAC까지 연동됩니다. OIDC Assume 문제를 해결한 뒤에도 배포가 막히면, 다음 단계는 대개 Kubernetes 인증/인가 쪽입니다. EKS 운영 이슈 전반의 진단 감각은 EKS CrashLoopBackOff 진단 - Pod 재시작 원인 추적 같은 글과 함께 보면 흐름을 잡기 좋습니다.
체크리스트: 3분 안에 결론 내기
- 워크플로에
permissions: id-token: write가 있는가? role-to-assumeARN이 맞는 계정/역할인가?- IAM에 OIDC Provider가 있고 URL이
token.actions.githubusercontent.com인가? - Role trust policy의
Principal.Federated가 OIDC Provider ARN과 일치하는가? - trust policy에서
aud == sts.amazonaws.com인가? - trust policy의
sub가 실제 실행 컨텍스트(ref/environment)와 일치하는가? - CloudTrail에서
AssumeRoleWithWebIdentity실패 사유가 조건 불일치인지, SCP/Boundary인지 확인했는가?
마무리
GitHub Actions OIDC + AWS STS AssumeRoleWithWebIdentity 조합에서 AssumeRoleAccessDenied는 대부분 신뢰 정책의 조건(aud/sub) 미스매치로 귀결됩니다. 해결의 핵심은 “정책을 넓혀서 일단 되게 만들기”가 아니라, 실제 토큰 클레임을 기준으로 최소 권한으로 정확히 맞추기입니다.
위 예제의 trust policy와 워크플로 템플릿을 기준으로 적용한 뒤, 브랜치/태그/Environment 전략에 맞춰 sub 조건을 설계하면 재발을 크게 줄일 수 있습니다.