Rule 1.5 — Minimal workflow permissions
Tier 1 · Baseline
Every workflow file declares a top-level permissions: block that scopes GITHUB_TOKEN to read-only by default. Jobs that need more escalate explicitly per-job.
Why it matters
Section titled “Why it matters”GITHUB_TOKEN is injected into every workflow run by GitHub. Without an explicit permissions: block, the token’s scope is governed by the repo’s “Workflow permissions” setting — and many repositories (especially older ones) still default to read and write, which means every step in every workflow can push code, modify issues and PRs, edit deployments, and upload to packages. If any third-party action in your chain gets compromised (see Rule 1.2’s discussion of the tj-actions/changed-files incident), the blast radius is whatever GITHUB_TOKEN could do.
An explicit permissions: { contents: read } at the top of every workflow caps the token’s authority. The tj-actions payload still ran on compromised repos — but on the ones with minimal permissions, what it could exfiltrate or modify with that token was bounded. Defense in depth.
The default for new orgs is now read-only, but old orgs and unmigrated repos remain on read-and-write, and the repo-level setting can be overridden by the workflow. Declaring permissions explicitly in every workflow means the workflow’s behaviour doesn’t depend on whichever default happens to be set at the org or repo level today.
How to do it
Section titled “How to do it”The minimum-viable block at the top of every workflow:
permissions: contents: readWhen a specific job needs more — uploading SARIF to Code Scanning, posting PR comments, pushing a release — declare it on the job, not at the top level. The job-level block replaces the workflow-level block (it is not additive), so list every scope the job needs:
permissions: contents: read
jobs: test: runs-on: ubuntu-24.04 # inherits permissions: contents: read
scorecard: runs-on: ubuntu-24.04 permissions: contents: read # still needed; job-level replaces workflow-level security-events: write # upload SARIF id-token: write # OIDC for Scorecard publish_results actions: read steps: ...Repo-level guardrail: in Settings → Actions → General → Workflow permissions, choose “Read repository contents and packages permissions” (the restrictive default). This caps what any workflow can ask for — a workflow asking for contents: write will still get contents: write, but a workflow with no permissions: block at all gets read-only instead of read-write. Set this at the org level too if you have one (“Organization → Settings → Actions → General”).
The scopes available are documented at GitHub Actions: Workflow permissions. The common ones:
contents: read repository content, push commits, manage releasespull-requests: read PRs, post review commentsissues: read issues, post commentssecurity-events: write SARIF to Code Scanningid-token: mint OIDC tokens (sigstore, cloud OIDC, SLSA generator, npm--provenance)actions: read workflow runs, cancel runspackages: read/write GitHub Packages
How to verify
Section titled “How to verify”-
deterministic-depsdoes not currently include a rule for workflow permissions; this is on the v1.x roadmap. Until then: -
Alternatives:
- OpenSSF Scorecard’s
Token-Permissionscheck (covered today). It fails when a workflow doesn’t declarepermissions:or when top-level permissions are over-broad. step-security/secure-reporewrites workflows to add minimalpermissions:blocks alongside SHA pinning.
- OpenSSF Scorecard’s
-
Manual one-liner: flag any workflow file without a top-level
permissions:declaration.Terminal window for f in .github/workflows/*.yml .github/workflows/*.yaml; do[ -f "$f" ] || continue# Look for a permissions: key at column 0 (workflow-level) within the first 30 lineshead -30 "$f" | grep -qE '^permissions:' || echo "$f: no top-level permissions"done
Common pitfalls
Section titled “Common pitfalls”- Setting top-level permissions, then a job-level block that drops a scope by accident. Job-level
permissions:replaces workflow-level; it does not extend. If your workflow haspermissions: { contents: read, id-token: write }and a job declarespermissions: { contents: write }, that job losesid-token: write. Always list every scope each job needs. - Forgetting
id-token: writefor OIDC. Sigstore signing, AWS / GCP OIDC, SLSA generators, andnpm publish --provenanceall needid-token: write. Without it, the call to the OIDC token endpoint fails with a confusing error. - Relying on the org default and never declaring permissions in the workflow. Org defaults change. Repos move between orgs. Whatever your default is today, declaring permissions in the workflow makes the behaviour portable.
- Trying to escalate permissions on PRs from forks. GitHub forces
GITHUB_TOKENfor fork PRs to read-only regardless of the workflow’spermissions:declaration — this is a hardcoded platform behaviour, not configurable. Design fork-PR workflows around it (e.g., only the merged commit gets full permissions; the PR-trigger workflow does read-only validation). - Confusing
permissions:(token scopes) withpermissions:on astep(runner sudo). They are unrelated. Workflow/jobpermissions:controlsGITHUB_TOKENscope; there is no step-levelpermissions:key.
Real example
Section titled “Real example”Ozark-Security-Labs/forkguard/.github/workflows/dependency-determinism.yml declares a workflow-level permissions: { contents: read, security-events: write } (the SARIF-upload pattern). The repo’s ci.yml uses the more typical permissions: { contents: read } since it only checks out and tests.