GitHub Actions permissions: Every Scope and What Needs It
Declaring a permissions: block replaces the defaults entirely rather than adding to them, so an incomplete block breaks steps that worked yesterday.
The GITHUB_TOKEN is minted per job with a permission set. It comes from repository or organisation defaults unless the workflow declares its own, and the moment it does, the declaration is the complete set.
That replacement behaviour is the single most common cause of a permissions failure appearing right after someone tightened security: the block lists the scope they were thinking about and silently drops the ones that were previously default.
Every scope
| Scope | Grants | Needed for |
|---|---|---|
actions | Workflows and runs | Cancelling runs, reading artifacts via API |
attestations | Artifact attestations | Provenance and supply-chain attestation |
checks | Check runs and suites | Publishing test results as checks |
contents | Repository contents | Checkout, pushing commits, creating releases |
deployments | Deployments | Creating deployment records |
discussions | Discussions | Commenting on discussions |
id-token | OIDC token | Cloud auth via OIDC. Must be write |
issues | Issues | Creating, labelling, commenting on issues |
packages | GitHub Packages | Pushing to GHCR |
pages | GitHub Pages | Deploying Pages |
pull-requests | Pull requests | Commenting, labelling, requesting review |
repository-projects | Projects | Updating project boards |
security-events | Code scanning | Uploading SARIF results |
statuses | Commit statuses | Setting commit status |
The replacement rule
# Before: repository defaults, whatever they are
# (no permissions block)
# After: contents is the ONLY permission this job has.
# A step that used to comment on a PR now fails with 403.
permissions:
contents: read
# Correct: list everything the job needs
permissions:
contents: read
pull-requests: write
checks: writeWhat no permissions block can grant
- Fork pull requests. A
pull_requestevent from a fork gets a read-only token and no access to secrets, by design and regardless of anypermissions:declaration. - Cross-repository access.
GITHUB_TOKENis scoped to the repository running the workflow. Another repository needs a PAT or a GitHub App token. - Anything above the repository or organisation default. If the default is restricted to read, a workflow cannot grant itself write.
- Triggering another workflow. Events created using
GITHUB_TOKENdo not start new workflow runs, which is a loop-prevention rule rather than a permission.
Common combinations
# push a container image to GHCR
permissions:
contents: read
packages: write
# authenticate to a cloud provider with OIDC
permissions:
contents: read
id-token: write
# comment on a PR and publish check results
permissions:
contents: read
pull-requests: write
checks: write
# upload code scanning results
permissions:
contents: read
security-events: writeDiagnose it: what token do you actually have?
Permission failures in Actions are almost never about your repository settings alone. Three things combine: the default GITHUB_TOKEN permission set for the repo or organization, the permissions: block in the workflow, and whether the event is a fork pull request, which downgrades the token to read-only regardless of everything else.
- name: Show the token scopes actually granted
run: |
curl -sI -H "Authorization: Bearer $GITHUB_TOKEN" \
https://api.github.com/ | grep -i "^x-oauth-scopes\|^x-accepted"
echo "event: ${{ github.event_name }}"
echo "fork PR: ${{ github.event.pull_request.head.repo.fork }}"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}Grant the narrowest permission that works
Declaring a permissions: block switches the job from the repository default to exactly what you list, so an incomplete block is a common cause of a new failure right after someone tightened security. List every scope the job needs, not just the one that failed.
permissions:
contents: read # checkout
packages: write # push to GHCR
id-token: write # OIDC to a cloud provider
pull-requests: write # comment on or label a PR
checks: write # publish check runsFrequently asked questions
Why did adding a permissions block break my workflow?
Why does my fork pull request get a 403?
GITHUB_TOKEN with no access to secrets, by design, so untrusted code cannot mutate the base repository. No permissions: declaration can raise it.What permission does OIDC need?
id-token: write. Without it the cloud credential action cannot request the OIDC token and fails with a generic credentials error rather than a permissions one.Why does my workflow not trigger another workflow?
GITHUB_TOKEN do not start new workflow runs. This prevents recursive loops and is not a permission setting; use a PAT or GitHub App token when a downstream trigger is genuinely required.