Skip to content

GITHUB_TOKEN permissions posture

Strict least-privilege rule for every workflow in .github/workflows/.

Invariants

  1. Top-level permissions: is the empty map {}. No scopes are granted at workflow scope; every scope must be declared per-job.
  2. Every job declares its own permissions: block. Inheritance from the (empty) top is not allowed; an omitted block fails the lint.
  3. No scalar or list top-level permissions: (read-all / write-all, or a sequence). Subsumed by rule 1; a string is reported with a dedicated message, any other shape (a sequence included) by its YAML kind and tag.

Why

Rule 1 is what stops a job from inheriting the repo-wide default: in a workflow with no top-level permissions: at all, every job that declares none of its own takes that default instead. The repo-wide default is set to read, but one UI flip to write would widen every such job at once, and nothing in the workflow file would record it.

Rule 2 then forbids leaning on the empty top-level block. A job that declares no scopes states nothing about what it needs, so the posture rests entirely on rule 1 holding at the top of the file. Reviewer-only enforcement is fragile: the omission is invisible in review precisely because nothing is written down.

Together the two rules keep every job's token surface stated in the file it runs from. Narrowing that surface is the per-job allowlist's job, below.

Enforcement

scripts/check-min-permissions.sh parses every workflow with yq and rejects:

  • top-level permissions: missing, non-empty map, scalar (read-all / write-all), or any other shape such as a list (read by its YAML kind and through an alias, as is a permissions: root key written as an alias, so an empty map carrying a tag of its own passes and an empty scalar carrying the map tag does not)
  • a workflow file yq reads as several YAML documents
  • any job whose permissions: block is omitted or not a map (read by its YAML kind, so a map carrying a tag of its own passes)
  • a merge key directly under jobs:, or a job key that is empty, is not a scalar, or holds a tab, a line break, a carriage return or a NUL, or the tag of a job id, a job or its permissions: holding a tab or a line break; that workflow's jobs are not read

Wired as the lint-workflow-security CI job (member check min-permissions) and as a pre-commit hook.

Per-job write-scope allowlist

min-permissions guarantees every job declares an explicit scope block, but it does not constrain which write scopes a block may grant. A hand-maintained allowlist closes that gap: .github/permission-scopes.yml pins, per job, exactly the write-valued scopes that job is permitted to hold. Widening a job's write surface means editing the allowlist, which makes every such change a reviewable, security-relevant diff.

Format

The allowlist is a per-job YAML map: workflow filename → job id → a sorted list of permitted write-scope names (only the scope lists are order-checked):

example-workflow.yml:
  build: [attestations, contents, id-token]
  notify: [issues]

Only scope names whose value is write are listed. Read scopes (and none) are unconstrained — they carry no least-privilege risk, so they need no entry, and a job with no write scopes is omitted entirely.

Enforcement (permission-scopes)

scripts/check-permission-scopes.sh cross-checks the live workflows against the allowlist in both directions and fails on any of:

  • Over-grant — a job grants a write scope that is not listed for that job in the allowlist. The token a compromised step could wield exceeds what was reviewed.
  • Stale entry — the allowlist lists a write scope the job no longer grants, or lists a scope under a workflow or job that no longer exists. Stale entries rot the allowlist into a misleading record of the actual write surface.
  • Unsorted scope list — a job's scope list departs from sorted order. Sorting keeps allowlist diffs minimal and blocks the duplicate-prone append-anywhere edit pattern.
  • Scalar job permissions — a job whose permissions: is neither a map nor the string read-all; a scalar grant bypasses the per-scope allowlist entirely (read-all is tolerated by this lint, but min-permissions still rejects any non-map permissions:, so the combined posture forbids it). How the block is read (by kind, through aliases) is stated in the header of scripts/check-permission-scopes.sh.
  • Unreadable job id or scope name — one that is not a scalar, is empty, or holds a tab, a line break or a NUL, or a merge key directly under jobs:; that workflow's jobs are not read.
  • Unreadable allowlist name — an allowlist workflow, job or scope name holding a tab or a line break, refused before the allowlist's rows are split, since it could forge a row for another entry.

Wired into the lint-workflow-security CI group and as the permission-scopes pre-commit hook.

Adding a workflow or job

  • Set top-level permissions: {} in the new workflow.
  • For each new job, add a permissions: block listing only the scopes that job needs. If the job does nothing more than checkout + run scripts on a public repo, contents: read is sufficient; for ghcr push, packages: write; for attestations, id-token: write + attestations: write; etc.
  • If the job grants any write scope, add it to .github/permission-scopes.yml in the same change, as <workflow>.yml: <job>: [sorted, scope, names]. An unlisted write scope is reported as an over-grant, which is a different failure from a missing block and is not fixed by adding one.

The min-permissions and permission-scopes lints catch these at pre-commit time; CI catches them on PR. Either way, the violation is surfaced before merge.