- Published on
GitHub Actions GITHUB_TOKEN 403 권한 오류 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
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 integration403: 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축을 같이 봐야 합니다.
- Workflow permissions 설정(리포지토리/조직 레벨)
- 워크플로 파일 내부의
permissions:선언 - 실행 컨텍스트(이벤트 종류, 포크 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
ghCLI는 기본적으로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분 안에 원인 찾기
아래 순서로 보면 대부분 빠르게 결론이 납니다.
- 이벤트 확인:
pull_request인지push인지, 포크 PR인지 - 리포지토리 설정: Workflow permissions가 read-only인지
- YAML permissions: job에
contents: write,pull-requests: write등 필요한 권한이 있는지 - 브랜치 보호: push 대상 브랜치가 보호되는지, 직접 push 허용되는지
- 대상 리소스: 같은 repo가 맞는지(다른 repo/조직이면 PAT/App token 필요)
- 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을 “랜덤 장애”가 아니라 “정책 위반 신호”로 빠르게 처리할 수 있습니다.