The GITHUB_TOKEN: default permissions, least privilege, and why read-all is a footgun
Every job gets an auto-minted GITHUB_TOKEN whose default scope can be write-all to the repo. Scope it per-job with permissions, drop to contents:read, and elevate only the one job that needs it.
The incident started with a comment-formatting Action nobody had pinned. It ran on every pull request, including PRs from forks, and the repo had never set a permissions: block — so the auto-minted GITHUB_TOKEN arrived with the legacy default: write access to almost everything in the repo. The Action had a transitive dependency that, three releases earlier, had been quietly republished by a compromised maintainer account. On the next fork PR, that dependency used the token it was handed to push a commit to a release branch and open a tag. No secret was stolen, no password phished. The CI simply did exactly what its token permitted, and the token permitted everything. The fix was four lines at the top of the workflow: permissions: contents: read. The post-mortem’s one-liner: the blast radius of a supply-chain compromise in CI is whatever the job token can do — and the default token could do nearly all of it.
What the token is, and where its scope comes from
When you see a supply-chain incident in CI, the first question is always: what could that compromised code actually do? The answer lives entirely in the token scope — so it’s worth knowing exactly how that scope is determined before you write your first permissions: block.
At the start of every job, GitHub mints a fresh installation access token and exposes it as secrets.GITHUB_TOKEN (and github.token). It is scoped to the running repository, and it expires when the job finishes — at most after the job’s run, never persisted. That expiry is the good news. The bad news is its breadth. The token has a set of permission scopes — contents, pull-requests, issues, packages, actions, id-token, and more — each independently read, write, or none.
Where do those scopes default? Two layers. Historically, repositories were created with the permissive default: the token got read/write on most scopes (contents: write, issues: write, pull-requests: write, …). GitHub later let orgs flip the org/repo setting to the restricted default (contents: read, everything else none), and new orgs increasingly start there — but you cannot assume it. The only scope you control with certainty is the one you write yourself:
permissions:
contents: read # clone the repo, nothing moreDeclare permissions: and every unlisted scope drops to none. That single block converts an ambient write-all token into a read-only one regardless of the repo’s default setting.
# Top-level: applies to all jobs as the floor
permissions:
contents: read
jobs:
release:
permissions: # job-level: overrides for this job only
contents: write # this one job may push a tag
id-token: write # and mint an OIDC token (next lesson)
runs-on: ubuntu-latest
steps: [...]A workflow has no permissions: block at all. The repo was created years ago and the org never changed the default token setting. What can the GITHUB_TOKEN in its jobs do?
Least privilege, per job
The discipline is two-part. First, set a restrictive floor at the top of the workflow — permissions: contents: read (or even {} for no scopes) — so every job starts with nothing it does not need. Second, elevate narrowly: only the job that publishes a release gets contents: write; only the job that comments on a PR gets pull-requests: write; only the job that authenticates to a cloud gets id-token: write. A job-level permissions: block fully replaces the inherited set for that job — it is not additive, so listing one scope drops all the others to none.
This matters because of how the token reaches code. Every step in a job — every third-party Action, every run: script, every transitive npm or pip dependency those execute — runs with the same job token available in the environment and to the GitHub API. There is no per-step sandboxing of the token. So the token’s scope is the union of trust you extend to every line of code in that job. Pinning Actions to a full commit SHA (uses: owner/action@<40-char-sha>) closes the which code runs half; least-privilege permissions: closes the what that code can do half. You need both: pinning without scoping still hands a write-all token to pinned-but-later-compromised code, and scoping without pinning still runs arbitrary mutable code, just with less reach.
▸Why this works
Why default to a top-level read-only floor instead of granting each job what it needs from scratch? Because the failure mode is forgetting. A new job added six months later inherits the top-level floor automatically; if the floor is contents: read, that new job is safe-by-default and only becomes dangerous if someone deliberately elevates it — a visible, reviewable line in the diff. With no floor, the new job silently inherits whatever the repo default is, and nobody notices the write-all token until the post-mortem. The floor turns “secure” into the path of least resistance.
A workflow sets permissions: contents: read at the top. One job needs to push a tag. A reviewer asks why not just grant contents: write at the top level so it's simpler. What is the correct objection?
- 01Where does the GITHUB_TOKEN's permission scope come from when a workflow has no permissions block, and why is that risky?
- 02Why scope contents:write to a single job instead of granting it at the top level, and how does that interact with pinning Actions?
GitHub mints a fresh GITHUB_TOKEN at the start of every job, scoped to the running repo and expiring when the job ends — but its breadth is the danger. Each scope (contents, issues, pull-requests, packages, actions, id-token) is independently read/write/none, and with no permissions block the token inherits the repo/org default, which may be the legacy permissive write-all set rather than the restricted read-only one. Declaring a permissions block makes every unlisted scope none, so the discipline is: set a top-level contents:read (or {}) floor, then elevate narrowly at the job level — only the release job gets contents:write, only the cloud-auth job gets id-token:write. Job-level blocks replace, not add. This matters because the token is shared by every step, Action, and transitive dependency in a job with no per-step sandbox, so its scope is the trust you extend to every line of code there. Pin Actions to a commit SHA to control which code runs and scope permissions to control what it can do; either alone leaves a real hole. A read-only floor also makes “secure” the default for jobs added later — they inherit safety and only become dangerous via a visible, reviewable elevation in the diff. Now when you open a workflow that has no permissions: block, you know exactly what you’re looking at: an implicit write-all grant waiting for one compromised dependency to use it.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.