Published on

GitHub Actions 403 권한오류 해결 - GITHUB_TOKEN·OIDC

Authors

서버나 애플리케이션 문제처럼 보이지만, 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가 자동 발급하는 토큰입니다. 장점은 관리가 쉽고 유출 위험이 상대적으로 낮다는 점이지만, 다음 제약을 꼭 이해해야 합니다.

  1. 기본 권한이 점점 보수적으로 바뀌는 추세
  2. PR from fork에서는 쓰기 권한이 차단(보안상 당연)
  3. 워크플로우/잡 단위로 permissions:를 명시하지 않으면 필요한 권한이 없을 수 있음

1) 가장 먼저 확인할 곳: Workflow permissions 설정

리포지토리에서:

  • SettingsActionsGeneralWorkflow permissions
    • Read and write permissions / Read repository contents permission
    • Allow 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가 막힐 수 있습니다.

  • SettingsBranches → 보호 규칙
  • “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은 크게 두 단계로 나뉩니다.

  1. OIDC 토큰 발급/교환 단계 실패(STS에서 거부)
  2. 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초 타임아웃