Workflow Permissions and the GITHUB_TOKEN
Every GitHub Actions run gets a temporary, auto-generated credential called GITHUB_TOKEN. It is created when a workflow starts, injected as a secret, and destroyed when the job finishes. What that token is allowed to do — read code, write issues, push tags, publish packages — is controlled by the permissions key. Getting this wrong is one of the most common ways CI pipelines turn a compromised dependency or a malicious pull request into a full repository compromise. This lesson covers how the token is scoped, how to write explicit permission blocks, and the mistakes that quietly grant far more access than a workflow needs.
Overview / How it works
GITHUB_TOKEN is not a personal access token and it is not one of your repository secrets, even though you reference it as secrets.GITHUB_TOKEN. GitHub mints a fresh one for each workflow run, scopes it to the repository the workflow lives in, and revokes it automatically when the job completes (or after a hard 24-hour ceiling). Because it is short-lived and repository-scoped, it is generally safer than a long-lived personal access token — but ‘safer’ does not mean ‘safe by default’.
Two things set the token’s starting permissions. First, the repository or organization setting under Settings > Actions > General > Workflow permissions, which offers either ‘Read repository contents permission’ (restrictive default) or ‘Read and write permissions’ (permissive default). Second, the permissions key inside the workflow file itself, which overrides that default for everything in the workflow, or for a single job. If a workflow file specifies no permissions block at all, the repository-level default applies, which means the token’s actual access can differ between repositories that otherwise look identical.
One case is fixed no matter what you configure: when a workflow is triggered by pull_request from a fork, GitHub automatically issues a read-only token with no access to repository secrets, regardless of the repository’s default settings. That protection is exactly what the higher-privilege pull_request_target event bypasses, which is why it deserves extra caution, covered later in this lesson.
Syntax or workflow structure
The permissions key can appear at the top level of a workflow, where it becomes the default for every job, or inside an individual jobs.<job_id> block, where it overrides the workflow-level value for that job only. Values are either a scope-by-scope map of read, write, or none, or one of the shorthand values read-all, write-all, or an empty map {} meaning no access at all.
| Scope | Controls |
|---|---|
contents |
Checkout, pushes, tags, releases |
pull-requests |
Commenting on, labeling, or merging PRs |
issues |
Creating or updating issues |
packages |
Publishing to GitHub Packages / GHCR |
id-token |
Requesting an OIDC token for cloud authentication |
checks |
Writing check-run results (status annotations) |
deployments |
Creating deployment records |
security-events |
Uploading code scanning / SARIF results |
A good default is to declare a restrictive workflow-level block, then grant additional scopes only on the specific job that needs them:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
# inherits contents: read, nothing more
publish:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
# only this job can publish packages
Examples
Example 1: a read-only test workflow. Most CI jobs only need to check out code and run a test suite — they never need to write anything back to GitHub.
name: CI Tests
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm test
Expected behavior: the token can clone the repository and nothing else. If npm test or any transitive dependency tries to push a commit, open an issue, or call the GitHub API to modify the repo, the call fails with a 403 — which is exactly the point.
Example 2: granting write access to one job only. Suppose a second job needs to post a build-result comment on the pull request. Rather than loosening the whole workflow, scope the extra permission to that one job.
name: Label and Comment
on:
pull_request:
types: [opened, synchronize]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test
comment:
needs: build
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: 'Build finished successfully.'
})
Expected behavior: the build job still runs with a read-only token. Only the comment job’s token can write to pull requests, and that access disappears once the job ends.
Example 3: a release job that needs several write scopes. Publishing a release, pushing a container image, and authenticating to a cloud provider each require a different scope. Requesting an OIDC token via id-token: write lets the job federate into a cloud role instead of storing a long-lived cloud credential as a secret.
name: Release
on:
push:
tags: ['v*.*.*']
permissions:
contents: read
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
id-token: write
steps:
- uses: actions/checkout@v4
- name: Create GitHub release
uses: softprops/action-gh-release@v2
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Push image
run: docker push ghcr.io/${{ github.repository }}:${{ github.ref_name }}
- name: Authenticate to cloud provider via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.DEPLOY_ROLE_ARN }}
aws-region: us-east-1
Expected behavior: only the release job, triggered only by a version tag push, can create releases, publish images, and assume the cloud role. Ordinary CI runs on branches and pull requests never touch these scopes.
Step by step
- Set the organization or repository default to ‘Read repository contents permission’ under Settings > Actions > General > Workflow permissions, so any workflow that forgets a
permissionsblock still fails closed. - Add a workflow-level
permissionsblock to every workflow file, even if it only setscontents: read. Do not rely on the inherited default being correct. - For each job, check whether it calls an action or script that writes to GitHub (comments, labels, releases, package pushes, check runs). Consult that action’s documentation for the exact scope it needs.
- Add the write scope only on the job that needs it, using a job-level
permissionsblock, leaving the rest of the workflow at its restrictive default. - Run the workflow and watch for 403 errors from the GitHub API, which usually mean a scope is missing; add exactly that scope rather than switching to
write-all. - For anything triggered by pull requests, confirm whether the trigger is
pull_request(safe, read-only, no secrets for forks) orpull_request_target(privileged, requires care) and treat the two very differently.
Common Mistakes
Mistake 1: reaching for write-all to make an error go away. When a step fails with a permissions error, it’s tempting to set the whole workflow to write-all and move on. This hands every dependency pulled in by npm ci or any other install step a token that can push code, edit releases, and modify repository settings for the entire run.
name: CI
on: [push, pull_request]
permissions: write-all
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test
The fix is to scope down to the single permission the job actually needs:
name: CI
on: [push, pull_request]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test
Mistake 2: using pull_request_target to build untrusted fork code. Unlike pull_request, the pull_request_target event runs in the context of the base repository, with access to its secrets and a token that follows your configured permissions rather than the automatic fork read-only restriction. Checking out the fork’s commit inside that context and then running its build scripts means untrusted code executes with a privileged token.
# UNSAFE: do not copy
name: PR Preview
on:
pull_request_target:
types: [opened, synchronize]
permissions:
pull-requests: write
jobs:
preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: npm ci
- run: npm run build
A malicious package.json install script in that fork could use the privileged token to exfiltrate secrets or push to the base repository. The safer pattern splits the work in two: an untrusted pull_request workflow builds and tests the fork’s code with a read-only, secret-less token and uploads an artifact; a separate, privileged workflow_run workflow, triggered only after that completes on the base repository, reads the result and posts the comment without ever checking out fork code.
name: Comment on PR (privileged, safe)
on:
workflow_run:
workflows: ['PR Build']
types: [completed]
permissions:
pull-requests: write
jobs:
comment:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.workflow_run.pull_requests[0].number,
body: 'Preview build complete.'
})
Best Practices
- Set the organization/repository default to read-only, so a workflow that omits
permissionsfails closed instead of open. - Declare an explicit
permissionsblock in every workflow file; never depend on inherited defaults being correct across repositories. - Grant write scopes at the job level, not the workflow level, so unrelated jobs in the same run cannot use a scope they never needed.
- Avoid
write-allandread-alloutside of quick local experiments — they defeat the purpose of scoping and are easy to forget to narrow later. - Prefer
id-token: writewith your cloud provider’s OIDC action over storing long-lived cloud credentials as repository secrets. - Pin third-party actions to a commit SHA, not just a version tag, since any action you call runs with the same token permissions your job was granted; explain to teammates that a tag can be moved by the action’s maintainer (or an attacker who compromises their account) while a SHA cannot.
- Treat
pull_request_targetas a privileged trigger: never check out and execute a fork’s commit inside it, and prefer the split-workflow pattern shown above. - Gate anything with meaningful write access (releases, deployments, package publishing) behind a protected environment with required reviewers, so a scoped token still cannot act without a human approval step.
Practice Exercises
- Take an existing workflow in one of your repositories that has no
permissionsblock. Add an explicit, minimalpermissionsblock at the workflow level, run it, and note which step (if any) starts failing with a 403 — then add only the scope that step needs, at the job level. - Write a two-job workflow where a
buildjob runs withcontents: readand a downstreampublishjob (usingneeds:) hascontents: writeandpackages: write. Confirm thebuildjob cannot perform a write action even though it runs in the same workflow. - Find a workflow in your organization that uses
pull_request_target. Determine whether it checks out the pull request’s head commit; if it does, redesign it using the splitpull_request/workflow_runpattern from this lesson.
Summary
GITHUB_TOKEN is a short-lived, repository-scoped credential whose actual power is set by the repository’s default workflow-permission setting and by the permissions key in your YAML. Fork pull requests always get a read-only, secret-less token under the plain pull_request event, which is why pull_request_target demands extra care. Default every workflow to contents: read, grant additional scopes like pull-requests: write, packages: write, or id-token: write only on the specific jobs that need them, and avoid write-all entirely. Combined with pinned action versions and protected environments, minimal token permissions keep a compromised dependency or a hostile pull request from turning into a compromised repository.
