"똑같이 만들었는데 새 레포만 배포가 안 된다" - GitHub Actions OIDC와 sub claim 변경
댓글 0
댓글을 작성하려면 로그인이 필요합니다.
아직 댓글이 없습니다. 첫 번째 댓글을 작성해보세요.
새 프로젝트의 배포 파이프라인을 만들었다. 기존 레포에서 잘 돌아가던 워크플로우를 그대로 복사하고, 인프라 코드도 같은 패턴으로 작성했다. 그런데 배포가 실패한다.
##[error]Could not assume role with OIDC: Not authorized to perform sts:AssumeRoleWithWebIdentity
같은 AWS 계정, 같은 조직, 같은 워크플로우 형식이다. 옆 레포는 같은 날 아침에도 멀쩡히 배포됐다. 오타를 세 번 확인해도 틀린 곳이 없다.
원인은 코드가 아니라 레포를 만든 날짜였다. GitHub이 2026년 7월 15일부터 OIDC 토큰의 sub claim 형식을 바꿨고, 그 이후에 만들어진 레포만 새 형식으로 토큰을 발급하고 있었다.
이 글에서는 OIDC가 무엇인지부터 시작해서, 이 문제를 어떻게 진단하고 해결했는지 정리한다.
먼저 두 단어를 구분해야 한다.
| 구분 | 영어 | 질문 |
|---|---|---|
| 인증 | Authentication | 당신은 누구인가? |
| 인가 | Authorization | 당신은 무엇을 할 수 있는가? |
OAuth 2.0은 원래 인가를 위한 프로토콜이다. "이 앱이 내 구글 드라이브 파일을 읽어도 된다"를 위임하는 방식이다. 그런데 사람들이 이걸 로그인에도 쓰기 시작했다. 토큰을 받아오면 "로그인된 것으로 치자"는 식이었다.
문제는 OAuth 2.0이 "이 토큰의 주인이 누구인지"를 표준으로 정의하지 않았다는 점이다. 서비스마다 사용자 정보를 가져오는 방식이 제각각이었다.
OIDC(OpenID Connect)는 OAuth 2.0 위에 인증 계층을 얹은 표준이다. OAuth 2.0의 흐름은 그대로 쓰면서, "이 토큰의 주인은 누구인가"를 표준화된 형식으로 함께 내려준다.
핵심은 ID 토큰이다. JWT 형식의 서명된 토큰으로, 안에 신원 정보가 claim이라는 이름의 필드로 들어 있다.
{
"iss": "https://token.actions.githubusercontent.com",
"sub": "repo:my-org/my-repo:ref:refs/heads/main",
"aud": "sts.amazonaws.com",
"exp": 1754812345
}
주요 claim은 다음과 같다.
| Claim | 의미 | 역할 |
|---|---|---|
iss | Issuer | 누가 발급했나 — 신뢰할 발급자인지 확인 |
sub | Subject | 누구에 대한 토큰인가 — 신원의 핵심 |
aud | Audience | 누구에게 주는 토큰인가 — 다른 곳에서 재사용 방지 |
exp | Expiration | 만료 시각 |
이 중 sub이 이 글의 주인공이다. 발급자가 "이 토큰은 이 대상의 것이다"라고 못 박는 값이고, 토큰을 받는 쪽은 이 값을 보고 권한을 준다.
전통적으로 GitHub Actions에서 AWS에 배포하려면 액세스 키를 GitHub Secrets에 저장했다. 이 방식에는 구조적인 문제가 있다.
OIDC 방식은 이 구조를 바꾼다. 저장된 비밀이 아예 없다.
1. 워크플로우 실행 → GitHub이 그 실행에 대한 ID 토큰을 즉석에서 발급
2. Actions가 그 토큰을 AWS STS에 제출
3. AWS가 토큰 서명을 검증하고, sub 값이 허용 목록에 있는지 확인
4. 통과하면 1시간짜리 임시 자격증명 발급
여기서 중요한 건 3번이다. AWS는 "이 토큰의 sub이 내가 허용한 값과 정확히 같은가"를 확인한다. 이 매칭이 IAM role의 trust policy에 문자열로 박혀 있다.
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:ref:refs/heads/main",
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
}
}
repo:my-org/my-repo:ref:refs/heads/main은 "my-org 조직의 my-repo 레포의 main 브랜치에서 실행된 워크플로우"라는 뜻이다. 브랜치까지 명시하므로 main에서 돌 때만 배포 권한이 나간다.
그리고 이 문자열이 바뀌면, 배포는 그 즉시 멈춘다. 오늘 일어난 일이 정확히 그것이었다.
정적 사이트 배포 파이프라인(S3 + CloudFront)을 새로 만들었다. 워크플로우는 조직 표준 형식 그대로다.
permissions:
id-token: write # OIDC 토큰 발급에 필수
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
# ... 체크아웃, 빌드 ...
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v5
with:
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
aws-region: ap-northeast-2
빌드는 매번 성공했다. 그리고 인증 단계에서 3번 연속 죽었다.
Assuming role with OIDC
Assuming role with OIDC
Assuming role with OIDC
... (12회 재시도, 약 2분)
##[error]Could not assume role with OIDC: Not authorized to perform sts:AssumeRoleWithWebIdentity
로그가 알려주는 건 이게 전부다. 어떤 sub이 거부됐는지, 무엇과 불일치했는지 한 글자도 없다. 이 메시지만 보면 role ARN 오타, 권한 부족, provider 설정 등 모든 것을 의심하게 된다.
원인을 찾기까지 약 40분이 걸렸다. 그 사이에 세운 가설들이다.
| # | 가설 | 기각한 근거 |
|---|---|---|
| A | Terraform이 아직 적용 안 됐다 | role CreateDate가 첫 실행보다 19분 빨랐다 |
| B | IAM 설정 전파 지연 | 19분이 지나도 동일하게 실패 |
| C | OIDC provider의 audience 불일치 | ClientIDList에 sts.amazonaws.com 존재 확인 |
| D | 액션 버전 / ARN 전달 방식 문제 | v4→v5, 하드코딩→vars 변경 후에도 동일 실패 |
| E | GitHub에서 예전 형식으로 되돌리면 된다 | 설정은 적용되는데 발급 토큰은 그대로였다 |
가설 D가 특히 뼈아프다. "우리 조직 표준 패턴대로 했는데 왜 안 되지"라는 전제에서 나온 가설이었다. 패턴을 아무리 정교하게 다듬어도 원인과는 아무 상관이 없었다.
돌파구는 관점을 바꾼 것이었다. GitHub 로그는 "거부당했다"만 말해준다. 그런데 거부한 쪽은 자기가 무엇을 받았는지 알고 있다.
AWS가 실제로 수신한 토큰의 sub은 CloudTrail에 그대로 기록된다.
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=EventName,AttributeValue=AssumeRoleWithWebIdentity \
--start-time "$(date -u -v-2H '+%Y-%m-%dT%H:%M:%SZ')" \
--max-results 30 --region ap-northeast-2 \
--query 'Events[].CloudTrailEvent' --output text \
| tr '\t' '\n' \
| jq -r '"\(.errorCode // "SUCCESS") | \(.userIdentity.userName)"' \
| sort -u
CloudTrailEvent는 이벤트 하나당 JSON 문자열 하나로 내려온다. 그래서 탭을 개행으로 바꿔 한 줄에 하나씩 만든 뒤 jq로 파싱한다.
출력은 이랬다.
AccessDenied | repo:my-org@123456789/my-repo@9876543210:ref:refs/heads/main
SUCCESS | repo:my-org/repo-c:ref:refs/heads/develop
SUCCESS | repo:my-org/repo-a:ref:refs/heads/main
한 화면에 성공과 실패가 나란히 찍히는 순간 변수가 하나로 좁혀졌다. 실패한 것만 sub 형식이 다르다. 조직명과 레포명 뒤에 @숫자가 붙어 있었다.
참고로 CloudTrail 이벤트에서
sub은userIdentity.userName에 들어 있다.requestParameters는null이라 거기서는 아무것도 얻을 수 없다.
GitHub이 2026년 4월 23일에 예고하고 2026년 7월 15일부터 자동 적용한 변경이다.
| 구분 | 형식 |
|---|---|
| 구 형식 | repo:OWNER/REPO:ref:refs/heads/main |
| 신 형식 | repo:OWNER@ORG_ID/REPO@REPO_ID:ref:refs/heads/main |
조직명·레포명 뒤에 변하지 않는 숫자 ID가 붙었다. 그래서 immutable subject claim이라고 부른다. 구분자로 @를 쓰는 이유는 GitHub 사용자명과 레포명에 @가 들어갈 수 없어 파싱이 모호해지지 않기 때문이다.
이게 이 변경의 핵심이고, 알고 나면 "왜 되돌릴 수 없게 만들었는가"까지 납득된다.
OIDC 명세는 sub이 절대 재할당되지 않을 것을 요구한다. 그런데 구 형식은 조직명과 레포명만 사용한다. 이름은 재사용이 가능하다.
1. my-org/deploy-target 레포가 삭제된다 (또는 조직이 사라진다)
2. 공격자가 같은 이름으로 조직·레포를 새로 만든다
3. 거기서 워크플로우를 돌리면 sub이 완전히 동일한 토큰이 발급된다
→ repo:my-org/deploy-target:ref:refs/heads/main
4. 피해자의 IAM trust policy는 이 값을 여전히 허용하고 있다
5. 공격자가 남의 AWS 계정에 배포 권한을 얻는다
이름은 재활용되지만 ID는 재활용되지 않는다. 불변 ID를 sub에 박으면 이 경로가 막힌다. 그리고 되돌릴 수 있게 두면 방어 자체가 무의미해지므로, GitHub은 되돌리는 방향의 스위치를 아예 제공하지 않는다.
이 부분이 실무에서 가장 위험하다.
| 대상 | 적용 여부 |
|---|---|
| 신규 생성 레포 | 자동 적용 |
| 이름을 바꾼 기존 레포 | 자동 적용 |
| 다른 조직으로 이전한 레포 | 자동 적용 |
| 기존 레포 (그대로 둔 경우) | 영향 없음 (명시적으로 opt-in 한 경우만 적용) |
| GitHub Enterprise Server | 해당 없음 (github.com 한정) |
기존 레포도 이름을 바꾸거나 다른 조직으로 옮기면 배포가 깨진다. 리네임과 이전은 배포와 아무 상관 없어 보이는 작업이라, 며칠 뒤에 터지면 원인을 연결하기가 훨씬 어렵다. 조직 개편으로 레포를 통째로 옮기는 상황이라면 특히 조심해야 한다.
실제로 조직 내 레포 4개를 조회해봤더니 경계가 정확히 드러났다.
레포 생성일(UTC) sub_claim_prefix
repo-a 2026-02-19T04:02:09Z repo:my-org/repo-a
repo-b 2026-06-15T06:10:55Z repo:my-org/repo-b
repo-c 2026-07-03T04:31:17Z repo:my-org/repo-c
my-repo 2026-08-10T02:59:09Z repo:my-org@123456789/my-repo@9876543210
7월 3일과 8월 10일 사이, 즉 2026-07-15 경계에서 갈렸다. 네 레포의 설정값은 완전히 동일하다.
배포를 붙이기 전에 미리 확인할 수 있다.
gh api /repos/<org>/<repo>/actions/oidc/customization/sub
{
"use_default": true,
"use_immutable_subject": false,
"sub_claim_prefix": "repo:my-org@123456789/my-repo@9876543210"
}
여기에 함정이 있다. use_immutable_subject가 false인데 실제 prefix는 신 형식이다. 이 플래그는 "명시적으로 opt-in 했는가"를 나타낼 뿐, 기본값이 무엇인지는 말해주지 않는다.
반드시 sub_claim_prefix 값을 직접 봐야 한다. @ID가 붙어 있으면 신 형식이다.
레포 OIDC 커스터마이즈 API로 구 형식을 강제해봤다.
gh api -X PUT /repos/<org>/<repo>/actions/oidc/customization/sub --input - <<'EOF'
{"use_default": false, "include_claim_keys": ["repo", "ref"]}
EOF
설정은 적용된다(use_default가 false로 바뀐다). 그런데 실제 발급되는 토큰은 여전히 신 형식이었다. CloudTrail로 확인한 결과다.
GitHub이 제공하는 토글은 기존 레포가 신 형식을 앞당겨 채택하는 방향이지 그 반대가 아니다. 조직 단위 설정으로 레포를 한꺼번에 전환할 수도 있는데, 이것도 방향은 마찬가지로 신 형식 쪽 한 방향뿐이다. 받는 쪽, 즉 클라우드에서 대응하는 것이 유일한 해법이다.
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:ref:refs/heads/main"
}
}
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": [
"repo:my-org/my-repo:ref:refs/heads/main",
"repo:my-org@123456789/my-repo@9876543210:ref:refs/heads/main"
]
}
}
Terraform이라면 이렇게 된다.
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:sub"
# 구 형식과 신 형식(immutable) 모두 허용.
# IAM은 값 목록을 OR로 평가하므로 StringLike가 필요 없다.
values = [
"repo:my-org/my-repo:ref:refs/heads/main",
"repo:my-org@123456789/my-repo@9876543210:ref:refs/heads/main",
]
}
핵심은 StringEquals에 정확한 문자열 두 개를 나열했다는 점이다. IAM은 조건 값 목록을 OR로 평가하므로 둘 중 하나만 맞으면 통과한다. 패턴 매칭이 전혀 필요 없다.
적용 후 재실행하니 바로 성공했다.
두 형식을 나열하는 게 번거로워 보여서, 처음에는 이렇게 제안했다가 보안 검토에서 반려됐다.
# 하지 말 것
test = "StringLike"
values = ["repo:my-org/my-repo:*"]
한 줄로 신·구 형식을 모두 커버하니 매력적으로 보인다. 그런데 :*는 ref 뿐 아니라 다른 컨텍스트까지 전부 매칭한다.
repo:my-org/my-repo:ref:refs/heads/main ← 의도한 것
repo:my-org/my-repo:ref:refs/heads/anything ← 아무 브랜치나 통과
repo:my-org/my-repo:pull_request ← PR 워크플로우도 통과
포크에서 올라온 PR의 워크플로우에도 프로덕션 배포 권한이 열린다. main 한정이라는 경계가 통째로 사라진다.
편의를 위해 넣은 와일드카드가 권한 경계를 무너뜨리는 전형적인 사례다. 인증 조건에서 와일드카드는 항상 "이 패턴에 또 뭐가 걸리는가"를 먼저 따져봐야 한다.
GitHub Actions 로그는 "거부됐다"까지만 알려준다. 무엇이 거부됐는지는 거부한 쪽이 알고 있다.
이건 OIDC에 국한된 이야기가 아니다. 인증이 실패했을 때 요청을 보낸 쪽의 로그보다 거절한 쪽의 로그가 항상 더 정확하다. AWS라면 CloudTrail, GCP라면 Cloud Audit Logs다. 이 순서를 먼저 떠올렸다면 40분이 10분이 됐을 것이다.
"조직 표준대로 했는데 왜 안 되지"라는 전제가 원인 규명을 늦췄다. 패턴은 처음부터 정상이었고, 변수는 레포 생성 시점이었다.
같은 방식으로 만든 것 중 일부만 실패한다면, 패턴을 의심하기 전에 대상들의 속성부터 나란히 놓고 비교하는 게 빠르다. 생성 시점, 버전, 리전 같은 것들이다. 이번에는 레포 4개의 생성일을 나열한 것이 답이었다.
이 문제는 새 레포를 만들 때마다, 그리고 레포를 리네임하거나 이전할 때마다 겪게 된다. 조직에 OIDC role 생성 표준이 있다면 지금 두 형식 병기로 갱신해두는 것이 최선이다. 신규와 기존을 모두 커버하므로 부작용이 없다.
한 발 더 나가고 싶다면, trust policy를 먼저 두 형식으로 바꿔둔 뒤 조직 설정에서 전체 레포를 신 형식으로 일괄 전환하는 방법도 있다. 순서만 지키면 무중단으로 형식을 통일할 수 있고, 이후로는 레포마다 형식이 다를 일이 없어진다.
AWS만의 이야기도 아니다. GCP Workload Identity Federation, Azure federated credentials도 sub을 그대로 매칭하므로 동일한 대응이 필요하다.
OIDC의 핵심은 결국 sub이라는 문자열 하나다. 발급자가 "이게 누구인지"를 선언하고, 수신자가 그 값을 보고 문을 열어준다. 그 문자열이 한 글자만 달라져도 문은 닫힌다.
편리한 추상화 뒤에 이런 단순한 문자열 매칭이 있다는 걸 알고 있으면, 오늘 같은 장애를 만났을 때 훨씬 빨리 접근할 수 있다.
지금 운영 중인 레포의 sub 형식을 한 번 확인해보는 건 어떨까. 명령어 한 줄이면 된다.
gh api /repos/<org>/<repo>/actions/oidc/customization/sub --jq .sub_claim_prefix
@ 가 보인다면, trust policy에 그 형식이 들어 있는지 지금 확인해보자.

코드를 수정하고, 테스트를 돌리고, 도커 이미지를 빌드하고, 레지스트리에 푸시하고, 서버에 접속해서 이미지를 pull 받고, 컨테이너를 재시작한다. 배포할 때마다 이 과정을 반복한다. 한두 번이야 괜찮다. 그런데 하루에 몇 번씩 배포하면? 명령어를 빼먹거나, 테스트를 건너뛰거나, 브랜치를 잘못 배포하는 실수가 생기기 시작한다. 사람이 반복하면 실수가 생기고

Dockerfile을 작성하고, 이미지를 빌드하고, 컨테이너를 띄웠다. 여기까지는 잘 된다. 그런데 실제 서비스는 앱 하나로 돌아가지 않는다. 웹 서버 뒤에는 데이터베이스가 있고, 캐시가 있고, 때로는 메시지 큐도 있다. 이걸 전부 으로 하나씩 띄우면 어떻게 될까? 컨테이너마다 네트워크를 연결하고, 포트를 매핑하고, 실행 순서를 맞추는 걸 매번 수동으로 해

도커로 컨테이너를 하나 띄우는 건 어렵지 않다. 한 줄이면 끝난다. 그런데 컨테이너가 10개, 100개로 늘어나면 얘기가 달라진다. 어느 서버에 올릴지, 몇 개를 띄울지, 하나가 죽으면 누가 다시 띄울지 — 직접 관리하기 시작하면 감당하기 어려워진다. 여기서 문제가 생긴다. 컨테이너는 만드는 것보다 운영하는 게 훨씬 어렵다. 서버가 죽어도 서비스는 살아 있

코드를 수정하고, 테스트를 돌리고, 도커 이미지를 빌드하고, 레지스트리에 푸시하고, 서버에 접속해서 이미지를 pull 받고, 컨테이너를 재시작한다. 배포할 때마다 이 과정을 반복한다. 한두 번이야 괜찮다. 그런데 하루에 몇 번씩 배포하면? 명령어를 빼먹거나, 테스트를 건너뛰거나, 브랜치를 잘못 배포하는 실수가 생기기 시작한다. 사람이 반복하면 실수가 생기고

Dockerfile을 작성하고, 이미지를 빌드하고, 컨테이너를 띄웠다. 여기까지는 잘 된다. 그런데 실제 서비스는 앱 하나로 돌아가지 않는다. 웹 서버 뒤에는 데이터베이스가 있고, 캐시가 있고, 때로는 메시지 큐도 있다. 이걸 전부 으로 하나씩 띄우면 어떻게 될까? 컨테이너마다 네트워크를 연결하고, 포트를 매핑하고, 실행 순서를 맞추는 걸 매번 수동으로 해

도커로 컨테이너를 하나 띄우는 건 어렵지 않다. 한 줄이면 끝난다. 그런데 컨테이너가 10개, 100개로 늘어나면 얘기가 달라진다. 어느 서버에 올릴지, 몇 개를 띄울지, 하나가 죽으면 누가 다시 띄울지 — 직접 관리하기 시작하면 감당하기 어려워진다. 여기서 문제가 생긴다. 컨테이너는 만드는 것보다 운영하는 게 훨씬 어렵다. 서버가 죽어도 서비스는 살아 있