Skip to content

Least-Privilege Permissions in GitHub Actions

Lesson 6 of 10Intermediate4 min readGitHub Actions & CI/CD · Actions SecurityVerified: GITHUB_TOKEN permission scopes, August 2026

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.

Repository and organisation settings choose between two defaults:

  • Permissive — read and write on most scopes.
  • Restrictedcontents: read and metadata: read only.

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.

permissions:
contents: read

What 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 scope
permissions: write-all # write on every scope — do not use
permissions: {} # nothing at all

permissions: {} is right for a job that touches no GitHub API — a pure build-and-test job.

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

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

ScopeGrants
actionsRead/manage workflow runs and artifacts
attestationsCreate artifact attestations
checksRead/write check runs
contentsRead/write repository contents, commits, branches, releases, tags
deploymentsRead/write deployments
discussionsRead/write discussions
id-tokenRequest an OIDC token — write only, grants no repository access
issuesRead/write issues, labels, comments
packagesRead/write GitHub Packages, including GHCR
pagesRead/write Pages deployments
pull-requestsRead/write pull requests and their comments
security-eventsRead/write code scanning alerts
statusesRead/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: write

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.

Find workflows with no explicit block:

Terminal window
for f in .github/workflows/*.yml; do
grep -q '^permissions:' "$f" || echo "no permissions block: $f"
done

Find over-broad grants across an organisation:

Terminal window
gh search code --owner ORG "write-all" --filename "*.yml" --limit 100

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

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.

  1. Audit your workflows for missing permissions: blocks with the loop above.

  2. Take one workflow, set permissions: {}, and run it. Note the first 403.

  3. Add exactly the scope it named. Repeat until it passes. Compare the result to what it previously had by default.

  4. Split a workflow so the build job has permissions: {} and only the publish job has packages: write.

  5. Open the Set up job log group and read the actual granted permissions.

  6. Set the organisation default to restricted, after confirming every workflow has an explicit block.

Check your understanding

3 questions — each one asks you to predict what Git or GitHub will do, not to recall a flag.

A workflow only runs tests and reports results in the log. Which permissions does it need?
Show answer

`contents: read` — nothing else — Reading the repository is all a test job does. Declaring `contents: read` sets every other scope to none, which is the least-privilege baseline the lesson recommends.

A workflow's `GITHUB_TOKEN` has `actions: write`. What can a compromised step do with that?
Show answer

Cancel and re-run workflows and delete artifacts and logs — including evidence of what it did — `actions: write` controls workflow runs, artifacts and logs. The lesson singles it out because it lets an attacker destroy the record of a compromise.

A workflow triggered by `pull_request` from a fork requests `contents: write`. What does it get?
Show answer

Read-only, regardless of what it requests — Fork pull requests get a read-only token no matter what the workflow declares. That restriction is what makes the `pull_request` event safe for untrusted code.

GitHub Actions Security ChecklistAudit your workflows against the failure modes that actually cause incidents. Free and complete.

Get the production security checklists and Actions hardening templates from the Professional Toolkit.