- Published on
GitHub Actions 403 권한오류 해결 - GITHUB_TOKEN·OIDC
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 애플리케이션 문제처럼 보이지만, GitHub Actions의 403은 대부분 토큰 스코프/권한 모델 변화, 워크플로우 권한 선언 누락, PR(특히 fork) 보안 제한, OIDC 신뢰 정책/대상 리소스 권한 불일치에서 발생합니다. 이 글은 GITHUB_TOKEN과 OIDC(Workload Identity Federation)를 중심으로, 로그 패턴별로 빠르게 원인을 좁히고 안전하게 해결하는 방법을 정리합니다.
> 비슷한 “권한은 있는데 특정 서비스만 403” 유형의 사고는 클라우드에서도 자주 나옵니다. EKS에서 IRSA는 되는데 S3만 403이 나는 케이스의 트러블슈팅 흐름도 참고하면 진단 감각을 키우는 데 도움이 됩니다: EKS IRSA는 되는데 S3만 403? 30분 진단
403 로그 패턴으로 원인 분류하기
Actions에서 403은 API마다 메시지가 조금씩 다릅니다. 먼저 어떤 API 호출이 막혔는지부터 확인하세요.
패턴 A: Resource not accessible by integration
- 주로 PR from fork에서
GITHUB_TOKEN으로 쓰기 작업(release 생성, issue/comment 작성, label 변경, 패키지 publish 등)을 시도할 때 발생 - 또는 workflow 권한이 기본
read로 떨어진 상태에서 write를 시도할 때 발생
패턴 B: Permission denied to GITHUB_TOKEN
permissions:가 명시되지 않았거나, 조직/리포 설정에서 기본 권한이 제한됨contents: write,packages: write,pull-requests: write등 필요한 스코프가 빠짐
패턴 C: GitHub API 403 + Requires authentication / Bad credentials
- 토큰이 아예 없거나(환경변수 미주입), 잘못된 토큰을 사용
actions/checkout에서persist-credentials: false설정 후 git push 시도 등
패턴 D: OIDC 연동에서 403
- 클라우드 STS(예: AWS
AssumeRoleWithWebIdentity)는 성공했는데, 이후 리소스 접근(S3/ECR/Secrets 등)에서 403 - 또는 STS 자체가
AccessDenied/InvalidIdentityToken로 실패
이제부터는 GITHUB_TOKEN 기반과 OIDC 기반으로 나눠 해결합니다.
GITHUB_TOKEN: 권한 모델과 403의 핵심
GITHUB_TOKEN은 워크플로우 실행 시 GitHub가 자동 발급하는 토큰입니다. 장점은 관리가 쉽고 유출 위험이 상대적으로 낮다는 점이지만, 다음 제약을 꼭 이해해야 합니다.
- 기본 권한이 점점 보수적으로 바뀌는 추세
- PR from fork에서는 쓰기 권한이 차단(보안상 당연)
- 워크플로우/잡 단위로
permissions:를 명시하지 않으면 필요한 권한이 없을 수 있음
1) 가장 먼저 확인할 곳: Workflow permissions 설정
리포지토리에서:
Settings→Actions→General→ Workflow permissionsRead and write permissions/Read repository contents permissionAllow GitHub Actions to create and approve pull requests등
조직 정책으로 강제되는 경우도 있으니, 리포 단에서 바꿔도 안 되면 조직 정책을 확인해야 합니다.
2) 최소 권한 원칙으로 permissions: 명시하기
403을 막겠다고 무작정 “전체 write”를 주면 보안 사고로 이어집니다. 필요한 스코프만 선언하세요.
아래 예시는 릴리즈 태그 푸시와 GitHub Release 생성을 하는 워크플로우입니다.
name: release
on:
push:
tags:
- 'v*'
permissions:
contents: write # tag/release 생성에 필요
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
패키지 레지스트리(GHCR 등)에 push한다면 packages: write가 필요합니다.
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
3) PR from fork에서 쓰기 작업을 하려다 403 나는 경우
fork PR은 공격자가 워크플로우를 악용해 secrets를 탈취할 수 있어 GitHub가 기본적으로 권한을 제한합니다.
해결 방향은 3가지입니다.
선택지 A: 쓰기 작업을 PR 워크플로우에서 제거
- PR에서는 테스트/빌드만 수행(읽기 권한)
- 머지 후
push(main)나release이벤트에서 publish 수행
name: ci
on:
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: ./gradlew test
선택지 B: pull_request_target 사용(주의)
pull_request_target은 베이스 브랜치 컨텍스트로 실행되어 secrets 접근이 가능해집니다. 대신 보안 위험이 큽니다.
안전하게 쓰려면:
- PR의 코드를 체크아웃해 실행하지 말 것
- 라벨/코멘트 같은 “리포 메타데이터 작업” 정도로 제한
name: pr-metadata
on:
pull_request_target:
types: [opened, synchronize]
permissions:
pull-requests: write
jobs:
comment:
runs-on: ubuntu-latest
steps:
- name: Comment
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body: 'Thanks for the PR!'
})
선택지 C: PAT(개인 토큰) 또는 GitHub App 토큰 사용
- 조직 정책상 필요할 수 있음
- 단, PAT는 유출 리스크가 커서 가능하면 GitHub App을 추천
git push/release에서 403이 나는 흔한 체크리스트
1) actions/checkout의 자격 증명 보존 여부
persist-credentials: false를 켜면 이후 git push는 인증이 없습니다.
- uses: actions/checkout@v4
with:
persist-credentials: true
- run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git push
2) contents: write 누락
태그/브랜치 푸시, 릴리즈 생성은 contents: write가 필요합니다.
3) 보호된 브랜치/태그 정책
브랜치 보호 규칙으로 인해 토큰이 write 권한이 있어도 push가 막힐 수 있습니다.
Settings→Branches→ 보호 규칙- “Require linear history”, “Restrict who can push” 등
OIDC: Secrets 없이 클라우드 접근하는데도 403이 나는 이유
OIDC는 GitHub Actions가 외부 IdP(예: AWS STS, Azure AD, GCP Workload Identity)에 단기 토큰을 받아 접근하는 방식입니다. PAT/Access Key를 저장하지 않아도 돼서 권장됩니다.
OIDC에서 403은 크게 두 단계로 나뉩니다.
- OIDC 토큰 발급/교환 단계 실패(STS에서 거부)
- STS는 성공했지만 리소스 권한이 부족(예: S3 PutObject 403)
1) OIDC 사용 시 필수: id-token: write
이게 없으면 GitHub가 OIDC 토큰을 발급하지 않아서 연동이 실패합니다.
permissions:
contents: read
id-token: write
2) AWS 예시: AssumeRoleWithWebIdentity + 403 진단
가장 흔한 구성은 aws-actions/configure-aws-credentials로 Role을 Assume 하는 것입니다.
name: deploy
on:
push:
branches: [main]
permissions:
contents: read
id-token: write
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: Deploy
run: |
aws s3 ls s3://my-bucket
여기서 403이 난다면 케이스가 갈립니다.
(1) STS Assume 자체가 실패한다면
- IAM Role의 Trust policy 조건이 맞지 않는 경우가 대부분
sub(subject),aud(audience),repository,ref조건 등을 엄격히 걸어놨는데 실제 실행 컨텍스트와 불일치
예: 특정 리포의 main 브랜치에서만 허용하는 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",
"token.actions.githubusercontent.com:sub": "repo:ORG/REPO:ref:refs/heads/main"
}
}
}
]
}
- 브랜치가
main이 아니라면? - 태그 이벤트라면
ref:refs/tags/v1.2.3형태로 바뀝니다. - 환경(environment) 보호 규칙을 쓰면
sub클레임 패턴이 달라질 수 있습니다.
(2) Assume은 성공했는데 S3/ECR 등에서 403이면
- 이제는 GitHub가 아니라 AWS IAM Permission 문제입니다.
- Role에 연결된 정책에
s3:PutObject,ecr:BatchGetImage같은 권한이 실제로 없거나 - 리소스 정책(S3 bucket policy, KMS key policy, ECR repository policy)이 Role을 거부하는 경우
이 단계의 진단은 “누가 거부했는지(Identity policy vs Resource policy vs SCP vs Permission boundary)”를 가르는 게 핵심입니다. 위에서 소개한 IRSA 403 글의 접근 방식이 그대로 적용됩니다: EKS IRSA는 되는데 S3만 403? 30분 진단
3) Azure/GCP도 결론은 동일: 신뢰(Trust)와 권한(Policy)을 분리해서 본다
- OIDC 교환 단계: “누가 이 토큰을 신뢰하나?”(issuer/audience/subject)
- 리소스 접근 단계: “이 주체가 무엇을 할 수 있나?”(RBAC/IAM)
실전: 403을 10분 안에 잡는 체크리스트
1단계: 어떤 토큰으로 호출했는가?
- GitHub API/리포 작업 →
GITHUB_TOKEN인지, PAT인지 - 클라우드 작업 → OIDC로 받은 임시 자격 증명인지
2단계: 워크플로우 권한 선언 확인
permissions:에 필요한 스코프가 있는가?- OIDC면
id-token: write가 있는가?
3단계: 이벤트 컨텍스트 확인
pull_request(fork?)인지push(main?)인지- fork PR이면 write 작업은 원칙적으로 불가
4단계: 리포/조직 정책 확인
- Workflow permissions 기본값이 read-only로 강제?
- 브랜치 보호로 push 제한?
5단계: OIDC라면 Trust vs Permission 분리
- STS Assume 실패 → Trust policy/subject/audience/ref 불일치
- STS 성공 후 403 → IAM/RBAC/리소스 정책 문제
권장 아키텍처: GITHUB_TOKEN은 GitHub 내부, 클라우드는 OIDC
정리하면 가장 안전하고 유지보수 좋은 조합은 다음입니다.
- GitHub 내부 작업(체크아웃, 릴리즈 노트, PR 코멘트 등):
GITHUB_TOKEN+ 최소permissions - 클라우드 배포(AWS/Azure/GCP): OIDC로 임시 자격 증명 발급 + 최소 IAM/RBAC
- fork PR은 테스트만 수행하고, 배포/퍼블리시는 main 머지 이후 이벤트에서 수행
이 패턴을 잡아두면 “갑자기 403” 같은 권한 사고가 나도, 토큰/권한/컨텍스트/신뢰정책을 계층적으로 분해해서 빠르게 복구할 수 있습니다.
> 인프라 자동화에서도 작은 정책 차이로 배포가 멈추는 일이 잦습니다. CI/CD에서의 권한·정책 디버깅 감각을 확장하고 싶다면 Terraform 실행이 멈추는 케이스 분석도 함께 보면 좋습니다: Terraform apply 멈춤 - AzureRM 120초 타임아웃