Published on

GitHub Actions GITHUB_TOKEN 403 권한 오류 해결

Authors

서론

GitHub Actions를 구성하다 보면 GITHUB_TOKEN을 이용해 git push, gh api, actions/cache, create release, create PR comment 같은 작업을 수행할 때 갑자기 403 Forbidden이 터지는 순간이 있습니다. 로그에는 대개 아래와 같은 메시지가 섞여 나옵니다.

  • remote: Permission to <org>/<repo>.git denied to github-actions[bot].
  • HttpError: Resource not accessible by integration
  • 403: Forbidden (write access to repository not granted)
  • This request has been blocked by security policy

핵심은 단순합니다. **GITHUB_TOKEN은 “항상 쓰기 가능”한 만능 토큰이 아니라, 이벤트·리포지토리 설정·브랜치 보호·환경 보호 규칙에 의해 권한이 크게 달라지는 “상황 의존 토큰”**입니다. 이 글에서는 403을 “증상”이 아니라 “권한 모델”로 분해해, 가장 흔한 원인부터 재현/해결 순서로 정리합니다.

> 403 자체가 애플리케이션/네트워크에서도 자주 보이지만, 원인 분석 방식은 비슷합니다. 정책/권한이 막는 403 디버깅 관점은 AWS WAF Bot Control 막힘으로 403 지속될 때도 참고가 됩니다.


403 유형 빠른 분류: 메시지로 원인 좁히기

403이더라도 메시지에 따라 원인이 거의 갈립니다.

1) Resource not accessible by integration

  • 포크(PR from fork)에서 실행 중이거나
  • pull_request 이벤트에서 권한이 제한된 경우
  • 또는 workflow 권한이 read-only인 경우

2) Permission denied to github-actions[bot]

  • contents: write가 없거나
  • 브랜치 보호 규칙으로 push가 막혔거나
  • 토큰은 write인데 “보호 브랜치에 직접 push 불가”인 경우

3) write access to repository not granted

  • 리포지토리 설정에서 Workflow permissions가 Read-only로 되어 있거나
  • 조직 정책이 write를 막고 있는 경우

GitHub Actions 권한 모델 핵심 3가지

문제를 빨리 풀려면 아래 3축을 같이 봐야 합니다.

  1. Workflow permissions 설정(리포지토리/조직 레벨)
  2. 워크플로 파일 내부의 permissions: 선언
  3. 실행 컨텍스트(이벤트 종류, 포크 PR 여부, 브랜치/환경 보호 규칙)

즉, YAML에 permissions: contents: write를 줘도 조직 정책이 read-only면 막힐 수 있고, 반대로 설정이 write여도 포크 PR이면 토큰이 read-only로 다운그레이드됩니다.


원인 1) 리포지토리 설정이 Read-only로 고정됨

가장 흔합니다. 리포지토리에서 다음 경로를 확인합니다.

  • Settings → Actions → General → Workflow permissions
    • Read and write permissions로 변경
    • 필요 시 Allow GitHub Actions to create and approve pull requests도 활성화

조직(Organization) 정책이 더 상위에서 강제하는 경우도 있으니, org 레벨 정책도 확인해야 합니다.

해결: workflow에 최소 권한 명시

리포지토리 설정을 바꿨더라도, 워크플로에서 권한을 명시하지 않으면 상황에 따라 기대와 다르게 동작할 수 있습니다. 권장 패턴은 기본 최소 권한 + 필요한 job에만 write입니다.

name: release
on:
  push:
    tags:
      - 'v*'

permissions:
  contents: read

jobs:
  create-release:
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v4
      - name: Create GitHub Release
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          gh release create "${GITHUB_REF_NAME}" --generate-notes
  • gh CLI는 기본적으로 GH_TOKEN을 읽습니다.
  • contents: write가 없으면 릴리즈 생성이 403으로 실패할 수 있습니다.

원인 2) pull_request 이벤트에서 write 작업을 시도함(특히 포크 PR)

보안상 GitHub는 포크에서 온 PR에 대해 기본적으로 토큰 권한을 제한합니다. 특히 다음 조합에서 자주 터집니다.

  • on: pull_request + git push/릴리즈 생성/PR 코멘트 작성 등 write 필요 작업
  • 외부 기여자의 포크 PR

해결 A: write가 필요한 작업은 pull_request_target로 분리

pull_request_target기본 브랜치 컨텍스트에서 실행되므로 토큰 권한이 상대적으로 강합니다. 하지만 체크아웃/실행 코드가 PR에서 온 코드를 그대로 실행하면 보안 사고로 이어질 수 있어, 반드시 방어적으로 구성해야 합니다.

name: pr-comment
on:
  pull_request_target:
    types: [opened, synchronize]

permissions:
  pull-requests: write

jobs:
  comment:
    runs-on: ubuntu-latest
    steps:
      - name: Comment with GH API
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
        run: |
          gh api \
            -X POST \
            repos/${{ github.repository }}/issues/${PR_NUMBER}/comments \
            -f body='CI가 실행되었습니다.'

주의: pull_request_target에서 PR 코드(특히 스크립트)를 실행하면, 악성 PR이 토큰을 탈취할 수 있습니다. write 작업만 하고, PR 코드 실행은 분리하거나 금지하는 편이 안전합니다.

해결 B: 포크 PR에서는 write 단계를 스킵

가장 안전하고 흔한 방식입니다.

on:
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm test

  publish:
    if: ${{ github.event.pull_request.head.repo.fork == false }}
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - run: echo "내부 브랜치 PR에서만 배포 단계 실행"

원인 3) 브랜치 보호 규칙으로 push가 금지됨

contents: write를 줬는데도 git push가 403이면, 보호 브랜치 규칙이 막는 경우가 많습니다.

대표 케이스:

  • main에 직접 push 금지
  • PR 필수
  • 상태 체크 필수
  • “관리자 포함” 옵션
  • “Require signed commits” 등

해결: 직접 push 대신 PR 생성으로 전환

릴리즈/버전 범프/포맷팅 결과를 반영하려고 main에 바로 push하는 대신, Actions가 브랜치를 만들고 PR을 열도록 바꾸면 대부분 해결됩니다.

name: bump-version
on:
  workflow_dispatch:

permissions:
  contents: write
  pull-requests: write

jobs:
  bump:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Configure git
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

      - name: Bump version
        run: |
          sed -i 's/"version": ".*"/"version": "1.2.3"/' package.json
          git checkout -b ci/bump-1.2.3
          git add package.json
          git commit -m "chore: bump version to 1.2.3"
          git push origin ci/bump-1.2.3

      - name: Open PR
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          gh pr create \
            --title "chore: bump version to 1.2.3" \
            --body "자동 버전 범프" \
            --base main \
            --head ci/bump-1.2.3

브랜치 보호가 강한 조직일수록 “CI가 직접 main을 수정”하는 패턴은 실패하기 쉽고, PR 기반 자동화가 운영에도 더 안전합니다.


원인 4) actions/checkout의 토큰 사용 방식 문제

actions/checkout@v4는 기본적으로 GITHUB_TOKEN을 사용해 fetch/push에 필요한 인증을 구성합니다. 그런데 다음 상황에서 문제가 생깁니다.

  • 다른 토큰(PAT, GitHub App token)을 써야 하는데 기본 토큰으로 checkout 함
  • 서브모듈/다른 private repo를 접근해야 함

해결: checkout에 명시적으로 token 지정

예: 다른 리포지토리에 push/접근해야 한다면 secrets.PAT 또는 GitHub App 토큰을 사용합니다.

- uses: actions/checkout@v4
  with:
    token: ${{ secrets.REPO_PAT }}
    submodules: recursive

> 보안/운영 관점에서 PAT보다 GitHub App 설치 토큰이 권장되는 경우가 많습니다(만료/권한 스코프/감사 측면).


원인 5) Environment 보호 규칙(Required reviewers)로 배포 단계가 막힘

Actions에서 environment:를 사용하는 job은 환경 보호 규칙의 영향을 받습니다.

  • 승인 필요(Required reviewers)
  • 배포 브랜치 제한
  • 대기 타임

이 경우 로그가 403처럼 보이기도 하고, job이 Pending/Blocked로 보이기도 합니다.

해결: 배포 job의 환경/브랜치 정책 정합성 확인

  • 배포 job에 연결된 environment 이름이 맞는지
  • 해당 environment에 허용된 브랜치/태그 패턴이 현재 실행 컨텍스트와 일치하는지
  • 승인자가 존재하고 승인 프로세스가 맞는지
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    permissions:
      contents: read
      id-token: write
    steps:
      - run: echo "승인 후 배포"

디버깅 체크리스트: 10분 안에 원인 찾기

아래 순서로 보면 대부분 빠르게 결론이 납니다.

  1. 이벤트 확인: pull_request인지 push인지, 포크 PR인지
  2. 리포지토리 설정: Workflow permissions가 read-only인지
  3. YAML permissions: job에 contents: write, pull-requests: write 등 필요한 권한이 있는지
  4. 브랜치 보호: push 대상 브랜치가 보호되는지, 직접 push 허용되는지
  5. 대상 리소스: 같은 repo가 맞는지(다른 repo/조직이면 PAT/App token 필요)
  6. API 엔드포인트 권한: 예를 들어 Packages는 packages: write, Issues 코멘트는 issues: write 또는 pull-requests: write가 필요

권한이 맞는데도 403이라면, “정책이 막는 403” 관점으로 접근해보는 게 좋습니다. 네트워크/보안 정책으로 막히는 사례는 EKS Pod에서 IPv6로만 STS 403 뜰 때 해결처럼 환경 의존적으로 나타나기도 합니다.


실전 예시: git push가 403일 때 가장 안전한 표준 해법

팀/조직에서 재사용 가능한 해법은 보통 아래 조합입니다.

  • 기본 권한은 read-only
  • write는 필요한 job에만 부여
  • 보호 브랜치에는 직접 push하지 않고 PR로 반영
  • 포크 PR에서는 write job 스킵

아래는 그 패턴을 한 파일에 담은 예시입니다.

name: ci
on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: echo "tests..."

  autofix:
    if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: apply formatting
        run: |
          echo "format" > formatted.txt

      - name: commit & PR
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
          BR="ci/autofix-${GITHUB_RUN_ID}"
          git checkout -b "$BR"
          git add -A
          git commit -m "chore: autofix" || exit 0
          git push origin "$BR"

      - name: open pr
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          gh pr create --title "chore: autofix" --body "자동 수정" --base main --head "$BR"

이렇게 바꾸면 보호 브랜치 정책과 충돌할 일이 줄고, 403이 나더라도 “왜 막혔는지”가 정책 단에서 명확해집니다.


결론

GitHub Actions의 GITHUB_TOKEN 403은 대개 “토큰이 틀렸다”가 아니라 권한이 의도보다 좁게 설정되었거나(설정/permissions), 실행 컨텍스트가 write를 허용하지 않거나(포크 PR), 리포지토리 정책이 직접 변경을 금지하기 때문에 발생합니다.

해결 전략은 한 가지로 수렴합니다.

  • 최소 권한을 기본값으로 두고
  • write가 필요한 job에만 permissions를 명시하며
  • 보호 브랜치에는 PR 기반으로 변경을 반영하고
  • 포크 PR에서는 write 단계를 과감히 분리/스킵합니다.

이 패턴으로 정리해두면, Actions가 늘어나도 403을 “랜덤 장애”가 아니라 “정책 위반 신호”로 빠르게 처리할 수 있습니다.