Every workflow run gets a GITHUB_TOKEN: minted at the start, revoked at the end, scoped to the
repository. What it can do with that scope is entirely up to you, and the default is often far more
than the workflow needs.
This is the cheapest security control in GitHub Actions. Two lines in a workflow file materially reduce what a compromised dependency can do.
The default, and why it matters
Section titled “The default, and why it matters”Repository and organisation settings choose between two defaults:
- Permissive — read and write on most scopes.
- Restricted —
contents: readandmetadata: readonly.
Restricted should be the organisation-wide default. Check Settings → Actions → General → Workflow permissions, and set it at the organisation level so new repositories inherit it.
With permissive defaults, a workflow that only runs tests holds a token that can push commits, create releases, and comment on issues. Every action it uses runs with that token available, so a compromised third-party action inherits it.
Declaring permissions
Section titled “Declaring permissions”permissions: contents: readWhat it doesRestricts the workflow token to the named scopes; every unnamed scope becomes `none`.
Why we run itAn explicit block is independent of repository settings, self-documenting, and survives a settings change. It also makes the intent reviewable in the diff.
Expected resultAny API call outside the granted scopes fails with 403, at the call rather than silently.
The key point that surprises people: declaring any permission sets every other scope to none. It
is not additive. permissions: {contents: read} means no packages, no issues, no pull requests.
That makes the block safe to write minimally — you cannot accidentally leave something enabled by listing too few scopes.
Two shorthands exist, and one is a trap:
permissions: read-all # read on every scopepermissions: write-all # write on every scope — do not usepermissions: {} # nothing at allpermissions: {} is right for a job that touches no GitHub API — a pure build-and-test job.
Workflow level and job level
Section titled “Workflow level and job level”permissions: contents: read
jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - run: npm test
publish: needs: test runs-on: ubuntu-latest permissions: contents: read packages: write steps: - run: ./publish.shA job-level block replaces the workflow-level one for that job. It does not merge, which is why
contents: read is repeated in publish — omitting it would leave the job unable to check out.
This is the pattern worth adopting everywhere: a minimal workflow-level default, with elevated permissions on the one job that needs them. The job running your dependencies and the job holding the write token should not be the same job — the split used by GitHub Pages deployment is exactly this idea.
The scopes
Section titled “The scopes”| Scope | Grants |
|---|---|
actions | Read/manage workflow runs and artifacts |
attestations | Create artifact attestations |
checks | Read/write check runs |
contents | Read/write repository contents, commits, branches, releases, tags |
deployments | Read/write deployments |
discussions | Read/write discussions |
id-token | Request an OIDC token — write only, grants no repository access |
issues | Read/write issues, labels, comments |
packages | Read/write GitHub Packages, including GHCR |
pages | Read/write Pages deployments |
pull-requests | Read/write pull requests and their comments |
security-events | Read/write code scanning alerts |
statuses | Read/write commit statuses |
Three carry more than they appear to:
contents: write includes pushing commits, creating and deleting branches and tags, and creating
releases. It is the broadest single grant available to the token.
actions: write can cancel and re-run workflows, and delete artifacts and logs — including the
evidence of what a compromised run did.
id-token: write is the one people over-fear. It permits requesting an identity token and grants
nothing on its own; see OIDC.
Common workflows and what they actually need
Section titled “Common workflows and what they actually need”{/* Tests only — no API access at all */}permissions: {}{/* Checkout and test */}permissions: contents: read{/* Publish a container image to GHCR */}permissions: contents: read packages: write{/* Comment on a pull request */}permissions: contents: read pull-requests: write{/* Upload code scanning results */}permissions: contents: read security-events: write{/* Deploy to a cloud via OIDC */}permissions: contents: read id-token: write{/* Build provenance attestation */}permissions: contents: read packages: write id-token: write attestations: writeFork pull requests are already restricted
Section titled “Fork pull requests are already restricted”On a pull_request from a fork, the token is read-only regardless of what you request, and
secrets are not passed. That is a platform decision, not something to work around: the code being run
was written by someone outside your organisation.
Requesting pull-requests: write in such a workflow does not fail — the request is simply not
honoured, and the API call fails at runtime. When a workflow needs to write on behalf of a fork pull
request, use the workflow_run split described in
workflow security, not pull_request_target.
Auditing what you grant
Section titled “Auditing what you grant”Find workflows with no explicit block:
for f in .github/workflows/*.yml; do grep -q '^permissions:' "$f" || echo "no permissions block: $f"doneFind over-broad grants across an organisation:
gh search code --owner ORG "write-all" --filename "*.yml" --limit 100For a single run, the permissions actually granted are printed in the log — expand Set up job → GITHUB_TOKEN Permissions. That is the authoritative answer when the file and the settings disagree.
Beyond the token
Section titled “Beyond the token”Two related controls belong in the same conversation:
Restrict which actions may run. Settings → Actions → General → Actions permissions can limit workflows to actions created by GitHub, verified creators, or an explicit allowlist. That is a stronger control than pinning, because it applies to every workflow in the repository at once.
Require approval for fork pull request workflows. By default, workflows from first-time contributors need approval. Tightening this to all outside collaborators means no fork code runs without someone looking at it first.
Exercise
Section titled “Exercise”-
Audit your workflows for missing
permissions:blocks with the loop above. -
Take one workflow, set
permissions: {}, and run it. Note the first 403. -
Add exactly the scope it named. Repeat until it passes. Compare the result to what it previously had by default.
-
Split a workflow so the build job has
permissions: {}and only the publish job haspackages: write. -
Open the Set up job log group and read the actual granted permissions.
-
Set the organisation default to restricted, after confirming every workflow has an explicit block.
Then what?
Section titled “Then what?”Check your understanding
3 questions — each one asks you to predict what Git or GitHub will do, not to recall a flag.