Skip to content
Latchkey

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

ScopeGrantsNeeded for
actionsWorkflows and runsCancelling runs, reading artifacts via API
attestationsArtifact attestationsProvenance and supply-chain attestation
checksCheck runs and suitesPublishing test results as checks
contentsRepository contentsCheckout, pushing commits, creating releases
deploymentsDeploymentsCreating deployment records
discussionsDiscussionsCommenting on discussions
id-tokenOIDC tokenCloud auth via OIDC. Must be write
issuesIssuesCreating, labelling, commenting on issues
packagesGitHub PackagesPushing to GHCR
pagesGitHub PagesDeploying Pages
pull-requestsPull requestsCommenting, labelling, requesting review
repository-projectsProjectsUpdating project boards
security-eventsCode scanningUploading SARIF results
statusesCommit statusesSetting commit status

The replacement rule

.github/workflows/ci.yml
# 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: write

What no permissions block can grant

  • Fork pull requests. A pull_request event from a fork gets a read-only token and no access to secrets, by design and regardless of any permissions: declaration.
  • Cross-repository access. GITHUB_TOKEN is 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_TOKEN do not start new workflow runs, which is a loop-prevention rule rather than a permission.

Common combinations

.github/workflows/ci.yml
# 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: write

Diagnose 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.

.github/workflows/ci.yml
- 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.

.github/workflows/ci.yml
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 runs

Frequently asked questions

Why did adding a permissions block break my workflow?
Because it replaces the defaults rather than adding to them. Once declared, the block is the complete permission set, so any scope you did not list is revoked for that job.
Why does my fork pull request get a 403?
Fork pull requests receive a read-only 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?
Events created using 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.

Related guides

References

Run this faster and cheaper on Latchkey managed runners - self-healing included. Start free → 30-day trial · No credit card