Workflow Permissions
Workflow permissions are the per scope grants on the token a GitHub Actions run uses, declared with a permissions key at workflow level or job level.
Workflow permissions in GitHub Actions are the per scope grants attached to the token a workflow run uses, declared with a permissions key at workflow level or at job level. Each scope, such as contents or packages, takes one of three values, read, write, or none, and every step in the run receives a token carrying exactly the grants that key resolved to.
When the key is absent, the run inherits a default set outside the workflow file, at the repository or organization level. That gap between what the file says and what the run receives is where most permission surprises begin.
Definition
A permissions block is a map from scope name to access level. The syntax and the full scope list are documented in GitHub's workflow syntax reference, checked on 2026-08-13.
permissions:
contents: read
packages: writeThree shorthand forms exist alongside the map. permissions: read-all grants read on every scope, permissions: write-all grants write on every scope, and permissions: {} grants none on every scope, which fits a job whose steps stay off the GitHub API.
The rule that surprises people
Naming any scope in a block sets every scope left out of that block to none. A block written to add packages: write for a publish step also removes the pull-requests: write a later step relied on, and the failure lands in a step nobody edited. A block replaces the default set outright rather than merging with it.
The same replacement applies between levels. A job level block replaces the workflow level set for that job outright rather than adding to it, so a job that needs one extra scope has to restate the scopes it already had.
What sits above the workflow file
Two limits apply regardless of what the file asks for.
Runs triggered by a pull request from a forked repository receive a read only token. That is the mechanism keeping a contributor's branch from pushing to the base repository or publishing under its name.
An organization can pin the default and restrict who may change Actions settings, so the value a run starts from is an administrative decision made once for many repositories. GitHub documents the two settings in configuring the default GITHUB_TOKEN permissions, checked on 2026-08-13.
The scopes a build usually touches
Write access on a scope includes the read access for that same scope. The table lists what write buys on the scopes that appear most often in build and release workflows.
| Scope | What write access allows |
|---|---|
contents | Push commits, create tags, create releases, update branches |
packages | Publish and update packages in the GitHub package registry |
pull-requests | Open, label, comment on, and close pull requests |
issues | Open, label, comment on, and close issues |
checks | Create and update check runs |
statuses | Set commit statuses on a ref |
actions | Cancel, re-run, and manage workflow runs |
deployments | Create deployments and deployment statuses |
security-events | Upload code scanning results |
id-token | Request an OpenID Connect token for cloud authentication |
id-token is the one entry with no read level. It accepts write or none, because requesting an identity token is the only operation the scope covers.
The failure signature
A missing scope surfaces as HTTP 403 from the API with the message Resource not accessible by integration. The message names no scope, so diagnosis means matching the API call in the failing step against the block that produced the token. A step calling gh pr comment needs pull-requests: write, a step running npm publish against the GitHub registry needs packages: write, and actions/checkout needs contents: read on a private repository.
Example
This workflow publishes a package on a version tag. Two grants at the top of the file describe the whole run.
name: publish
on:
push:
tags: ['v*']
permissions:
contents: read
packages: write
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
registry-url: https://npm.pkg.github.com
- run: npm ci
- run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}contents: read lets actions/checkout fetch the tagged commit. packages: write lets npm publish push the tarball to the registry. Every other scope resolves to none for this run, because the block named two scopes and silence on the rest means denial. Adding a step later that comments on a pull request returns 403 until pull-requests: write joins the block.
What applies when the block is absent
Delete those three lines and the run still starts. What changes is the origin of the grants: the token now carries the repository default under Settings, Actions, General, Workflow permissions, or the organization value when an administrator pinned it. The permissive setting grants write access on most scopes, and the restricted setting grants read access on contents and packages.
So the same file publishes successfully in a permissive repository and fails at npm publish in a restricted one, with no difference between the two workflow files. Writing the block down removes that dependency on a setting the file cannot see.
Read only default with one job widened
The shape most release pipelines settle on is a narrow default at the top and a single job that restates what it needs.
name: release
on:
push:
branches: [main]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test
release:
needs: test
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
id-token: write
steps:
- uses: actions/checkout@v4
- run: ./scripts/release.sh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}The test job inherits contents: read and nothing else. The release job replaces that set with three grants of its own, which is why contents: write has to be repeated there even though the top of the file already granted read. id-token: write is present so the release script can exchange an OpenID Connect token for cloud credentials instead of reading a stored key.
Related Terms
- GITHUB_TOKEN, the credential these grants are attached to: where the token comes from, how long it lives, and what a step sees when it reads
secrets.GITHUB_TOKEN. - How to set permissions for GITHUB_TOKEN in a workflow: the workflow level default plus per job widening, with the scope table for checkout, package publishing, and status updates.
- Managing secrets in GitHub Actions workflows: repository, environment, and organization secrets, and the OpenID Connect path that removes the stored credential.
- WarpBuild runner security documentation: how runner isolation, storage, and secrets handling are documented.
- Running workflows from public repositories: the runner group setting a public repository workflow needs before a job is scheduled.
- WarpBuild pricing: per minute rates by runner type.
FAQ
Where does a permissions block go in a workflow file?
At the top level of the file, inside a single job, or both. A top level block sets the grants for every job in the run. A job level block replaces that set for one job. The common shape is a read only default at the top and one job widened to the scopes it actually calls.
What happens when a workflow has no permissions block?
The run uses the default configured for the repository, or the organization value when an administrator has pinned it. GitHub documents two choices for that default: permissive, which grants write access on most scopes, and restricted, which grants read access on contents and packages. The workflow file carries no signal about which one applies, so the same file behaves differently across repositories.
Why does a step fail with Resource not accessible by integration?
The token is missing the scope that API call requires. Naming any scope in a permissions block drops every scope left out to none, so adding one grant can remove another that a later step depended on. Match the API calls in the failing step against the scopes in the block and add the missing one.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.