GitHub Actions 워크플로우 권한 설정 실전 – permissions 최소화 가이드

목차

GitHub Actions 워크플로우 권한 설정은 워크플로우 실행 시 자동 발급되는 GITHUB_TOKEN의 접근 범위를 permissions 키로 제한하는 메커니즘이다. 토큰은 잡이 시작될 때 발급되고 끝나면 폐기된다. 문제는 이 토큰의 기본 권한 범위가 의외로 넓다는 점이다.

반면, 처음 권한 최소화를 시도했을 때 워크플로우 파일 맨 위에 permissions: contents: read 한 줄만 박아두면 끝일 줄 알았다. 매트릭스 빌드 잡 하나가 통째로 깨졌고, 그 뒤로 권한이라는 게 워크플로우 레벨과 잡 레벨에서 어떻게 다르게 동작하는지 한참 들여다봤다. 그러고 나서야 흔히 도는 "일단 풀어두고 차차 줄여라"라는 조언이 실제로는 함정에 가깝다는 결론에 도달했다.

GITHUB_TOKEN이 실제로 가진 권한 범위

그러나, GITHUB_TOKEN은 워크플로우가 시작될 때 러너에 자동 주입되는 일회용 인증 토큰이다. 외부 PAT(Personal Access Token)와 달리 잡 종료 시점에 무효화된다. 보안 측면에서 깔끔해 보이지만 기본 권한 범위가 넓게 잡혀 있다.

리포지토리 Settings > Actions > General의 "Workflow permissions" 항목을 보면 두 가지 옵션이 있다. "Read and write permissions"와 "Read repository contents and packages permissions"다. 2023년 2월 이후 새로 생성된 리포지토리는 후자가 기본값으로 설정되어 있다(GitHub Changelog, 2023-02-02). 그런데 그 이전에 만든 리포지토리는 여전히 "Read and write"가 기본값으로 남아 있는 경우가 많다. 조직 단위로 일괄 변경하지 않으면 오래된 리포는 계속 위험한 상태로 방치된다.

따라서, 권한 종류는 10가지 정도다. actions, checks, contents, deployments, id-token, issues, packages, pull-requests, security-events, statuses 등. 각각 read/write/none 중 선택할 수 있다. 워크플로우 파일에 permissions 키를 안 적으면 위에서 말한 리포지토리 기본값이 적용된다.

권한 키가 가진 의미를 한 번 더 따져보기

contents: write는 단순히 코드 푸시만 가능한 게 아니다. 태그 생성, 릴리스 발행, 브랜치 삭제까지 포함한다. pull-requests: write도 PR 코멘트 작성, 라벨 추가, 머지 트리거가 다 포함된다.

문제는 워크플로우 코드 한 줄만 보면 이 차이가 안 보인다는 점이다. permissions: write-all이라고 쓰면 모든 권한이 전부 write로 풀린다. 이게 자동으로 안 좋은 건 아니다. 다만 누가 워크플로우 파일을 PR로 수정해서 임의의 스크립트를 끼워 넣으면 그대로 토큰을 탈취당할 수 있다는 게 문제다.

"일단 풀어두고 차차 줄여라"가 위험한 이유

예를 들어, 권한 최소화에 관한 흔한 조언이 있다. "처음엔 write-all로 풀어두고 워크플로우가 안정되면 점진적으로 줄여라." 사람들이 이렇게 말하는 이유는 명확하다. 권한 부족으로 잡이 실패하는 디버깅이 짜증나기 때문이다. 어떤 권한이 어디서 쓰이는지 추적하려면 잡 로그를 뒤져야 하는데, 처음 도입할 때는 그 매핑이 익숙하지 않다.

문제는 "차차 줄이는 시점"이 사실상 안 온다는 거다. 한 번 풀어두면 그 워크플로우가 어떤 권한을 실제로 쓰는지 추적하는 노력이 더 든다. 잡 로그에서 토큰을 어디다 썼는지 일일이 봐야 하고, 빠뜨린 권한 하나 때문에 며칠 뒤에 또 실패한다. 어차피 한 번은 깨질 거라면, 처음부터 read로 시작해서 필요한 만큼만 풀어주는 쪽이 시행착오 횟수가 비슷하거나 더 적다.

풀어둔 권한이 공격 표면이 되는 시나리오

가장 흔한 패턴이 pull_request_target 트리거를 쓰는 워크플로우다. 이 트리거는 base 브랜치 컨텍스트에서 실행되기 때문에 외부 fork에서 온 PR에서도 풀 권한 GITHUB_TOKEN을 받는다. 워크플로우에서 PR 본문이나 코멘트에 있는 문자열을 셸 변수에 그대로 넣어 실행하는 코드가 있으면, PR을 만든 사람이 임의 코드를 실행시킬 수 있다.

이 시나리오는 PoC 수준이 아니라 실제로 다수의 오픈소스 프로젝트가 당한 경로다. 2022~2024년에 GitHub Actions 관련 공급망 이슈가 여러 차례 보고되었다(예: GitHub 보안 권고 GHSA-mrrh-fwg8-r2c3 등 유사 케이스 참고). 토큰이 write 권한을 다 갖고 있으면 그대로 릴리스 태그를 조작하거나 자기 코드를 main에 푸시할 수 있다. 권한이 read-only였다면 같은 공격이 정보 노출 정도에서 멈췄을 거다.

워크플로우 한 번만 거는 함정과 잡 레벨 분리

그래서, 처음 권한 최소화를 시도하는 사람들이 많이 쓰는 패턴이 워크플로우 파일 맨 위에 permissions: 블록 하나만 거는 방식이다.

# .github/workflows/ci.yml
permissions:
  contents: read

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

  deploy:
    runs-on: ubuntu-latest
    needs: test
    steps:
      - uses: actions/checkout@v4
      - run: ./deploy.sh   # 여기서 태그 푸시 필요

이처럼, 이 워크플로우는 deploy 잡에서 깨진다. ./deploy.sh가 태그를 푸시하는데 토큰에 contents: write가 없기 때문이다. 그렇다고 워크플로우 레벨에서 contents: write로 올려버리면 test 잡까지 같이 권한이 풀린다. 권한 분리의 의미가 사라지는 셈이다.

해법은 잡 레벨에서 권한을 따로 거는 방식이다.

permissions:
  contents: read   # 워크플로우 전역 기본값

jobs:
  test:
    runs-on: ubuntu-latest
    # 추가 권한 없음 - contents: read만 상속
    steps:
      - uses: actions/checkout@v4
      - run: pytest

  deploy:
    runs-on: ubuntu-latest
    needs: test
    permissions:
      contents: write   # 이 잡만 write로 승격
    steps:
      - uses: actions/checkout@v4
      - run: ./deploy.sh

결국, 잡 레벨의 permissions는 워크플로우 레벨을 완전히 덮어쓴다. 즉, 잡에서 contents: write만 적으면 pull-requests, issues 같은 다른 권한은 자동으로 none이 된다. 이 동작이 처음엔 헷갈리는데, 알고 나면 깔끔하다. 잡 레벨 블록을 일종의 화이트리스트로 보면 된다.

매트릭스 잡에서의 권한 처리

매트릭스 빌드를 쓰면 권한이 매트릭스 셀마다 동일하게 적용된다. 잡 안에서 매트릭스 셀별로 권한을 다르게 줄 방법은 작성 시점(2026년 5월) 기준 없다. 권한을 다르게 줘야 한다면 잡을 분리해야 한다.

즉, 이걸 모르고 매트릭스 잡 하나에 모든 환경 배포(staging, production)를 몰아넣었다가 두 환경이 같은 권한으로 묶이는 상황을 봤다. 환경별로 잡을 나누면 GitHub Environments 기능과 결합해서 시크릿/권한도 분리할 수 있다. Environment에 보호 규칙을 걸어두면 production 잡은 수동 승인을 받아야 토큰이 발급된다.

잡 유형별 최소 권한 매핑

특히, 자주 쓰는 잡 유형에 필요한 최소 권한을 정리해두면 워크플로우 처음 짤 때 시간을 꽤 아낀다.

잡 유형 필요한 권한 비고
단순 빌드/테스트 contents: read actions/checkout만 쓰는 경우
도커 이미지 → GHCR 푸시 contents: read, packages: write docker/login-action 사용 시
OIDC로 AWS 인증 contents: read, id-token: write aws-actions/configure-aws-credentials
PR에 코멘트 작성 contents: read, pull-requests: write actions/github-script
릴리스 생성 contents: write tag 푸시 포함
Dependabot 자동 머지 contents: write, pull-requests: write 별도 워크플로우 권장
보안 스캔 결과 업로드 contents: read, security-events: write CodeQL, Trivy 등
GitHub Pages 배포 contents: read, pages: write, id-token: write actions/deploy-pages

id-token: write는 OIDC를 쓰는 모든 경우에 필요한데, 처음 도입할 때 흔히 놓치는 부분이다. 클라우드 측 IAM 설정은 맞췄는데 GitHub 측 권한을 안 줘서 토큰이 발급 안 되는 게 가장 자주 보는 케이스다.

외부 fork PR에서 토큰이 어떻게 달라지는가

GITHUB_TOKEN은 트리거 타입에 따라 권한이 자동으로 줄어들거나 유지된다. 이게 보안의 마지막 방어선이다.

pull_request 트리거에서 외부 fork PR이 들어오면 토큰의 권한이 자동으로 read-only로 강등된다. permissions: write-all이라고 적어도 실제 발급되는 토큰은 read만 가능하다. GitHub이 기본으로 깔아둔 안전장치다(출처: GitHub Docs – Automatic token authentication).

반면 pull_request_target 트리거는 base 브랜치 컨텍스트에서 실행되기 때문에 외부 fork에서도 풀 권한 토큰을 받는다. 이 트리거를 쓸 때는 PR 코드를 절대로 신뢰하면 안 된다. PR 본문의 문자열을 변수에 넣어 셸에서 평가하는 패턴, PR이 수정한 파일을 그대로 실행하는 패턴은 특히 위험하다.

GITHUB_TOKEN과 PAT의 차이

가끔 "GITHUB_TOKEN으로 안 되는 게 있어서 PAT을 썼다"는 워크플로우를 본다. 이유는 보통 두 가지다.

따라서, 첫째, GITHUB_TOKEN으로 푸시하면 다른 워크플로우가 트리거되지 않는다. 무한 루프 방지용 동작이다. 릴리스 봇이 태그를 푸시했을 때 그 태그로 트리거되는 deploy 워크플로우가 안 도는 게 이 경우다. 이때는 PAT을 쓰거나 GitHub App 토큰을 쓰는 방법이 있다.

둘째, 다른 리포지토리를 건드려야 할 때다. GITHUB_TOKEN은 자기 리포에만 권한이 있다. cross-repo 작업에는 PAT 또는 GitHub App 토큰이 필요하다.

예를 들어, 가능하면 GitHub App 토큰을 쓰는 게 PAT보다 낫다. 만료 관리가 자동이고 권한도 더 세밀하게 조정된다. PAT은 개인 계정에 묶여 있어서 해당 직원이 떠나면 워크플로우가 깨진다. 이런 사고를 한 번 겪고 나면 App으로 갈아탄다.

권한 설정에서 자주 깨지는 시나리오

권한 최소화를 도입할 때 흔히 마주치는 시행착오를 미리 알아두면 깨질 때 당황하지 않는다.

첫째, OIDC 사용 시 id-token: write 누락. 클라우드 OIDC 통합에서 가장 흔한 실수다. 에러 메시지가 명확하지 않아서 처음 보면 한참 헤맨다. Error: Could not load credentials from any providers 같은 메시지가 떠도 실제 원인은 권한 누락인 경우가 있다. 메시지만 보면 자격증명 자체가 없는 것처럼 보이지만, 실제로는 GitHub 측에서 OIDC 토큰을 발급하지 않은 상태다.

그러나, 둘째, pull-requests: write 없이 PR 코멘트 시도. gh pr comment 또는 actions/github-script로 코멘트를 달려는데 403이 뜬다. Error: Resource not accessible by integration 메시지가 함께 나오면 권한 문제다.

게다가, 셋째, Dependabot 워크플로우 권한 분리 누락. Dependabot이 만든 PR은 별도의 dependabot 컨텍스트에서 실행된다. 이때 시크릿과 권한이 일반 워크플로우와 다르게 적용된다. Settings > Secrets and variables > Dependabot 항목에서 따로 관리해야 한다. 일반 시크릿이 안 보인다고 처음에 한참 헤맸는데, 알고 보니 Dependabot 워크플로우는 별도 시크릿 풀을 쓴다는 게 원인이었다. 한 번 본 이슈는 잊기 어렵다.

당장 적용할 수 있는 액션과 다음 실험

그러나, 지금까지의 설정을 리포지토리 약 50개에 적용해보니 운영 부담은 크지 않다. 처음에 워크플로우 깨지는 거 몇 번 잡고 나면 그 다음부터는 새 워크플로우 짤 때 자연스럽게 최소 권한으로 시작하게 된다. 당장 적용해볼 수 있는 액션 세 가지를 뽑으면 이렇다.

  1. 조직 Settings > Actions에서 "Workflow permissions"의 기본값을 read-only로 변경한다. 오래된 리포지토리에도 일괄 적용된다.
  2. 모든 워크플로우 상단에 permissions: contents: read를 명시적으로 박아둔다. 리포 설정이 바뀌어도 워크플로우는 독립적으로 안전해진다.
  3. 잡 레벨 permissions로 필요한 권한만 화이트리스트 방식으로 추가한다. write가 필요한 잡은 의도적으로 분리한다.

한편, 다음에는 OPA(Open Policy Agent) 또는 reusable workflow 패턴을 활용해서 권한 설정 자체를 정책으로 강제하는 방법을 실험해볼 생각이다. 워크플로우 파일을 PR로 받을 때 permissions: write-all 같은 패턴이 있으면 CI에서 차단하는 게 목표다.

관련 글