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

  1. Set the organization or repository default to ‘Read repository contents permission’ under Settings > Actions > General > Workflow permissions, so any workflow that forgets a permissions block still fails closed.
  2. Add a workflow-level permissions block to every workflow file, even if it only sets contents: read. Do not rely on the inherited default being correct.
  3. 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.
  4. Add the write scope only on the job that needs it, using a job-level permissions block, leaving the rest of the workflow at its restrictive default.
  5. 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.
  6. For anything triggered by pull requests, confirm whether the trigger is pull_request (safe, read-only, no secrets for forks) or pull_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 permissions fails closed instead of open.
  • Declare an explicit permissions block 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-all and read-all outside of quick local experiments — they defeat the purpose of scoping and are easy to forget to narrow later.
  • Prefer id-token: write with 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_target as 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 permissions block. Add an explicit, minimal permissions block 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 build job runs with contents: read and a downstream publish job (using needs:) has contents: write and packages: write. Confirm the build job 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 split pull_request / workflow_run pattern 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.