GitHub Actions · beginner
How to verify GitHub Actions permissions are least-privilege
Tightening GitHub Actions permission scopes by reading the workflow's actual API surface, comparing it against declared permissions, and walking the change through branch protection, environments, and a workflow_dispatch smoke test before merge.
The symptoms
- •The workflow runs green but uses a custom GITHUB_TOKEN with broader scopes than any step actually requires (visible in Actions logs or via the REST API token inspection endpoint).
- •A repository rule "Require approval for all outside collaborators" or "Restrict workflow permissions" is failing because the .github/workflows/*.yml file declares permissions: write-all or omits a permissions: block entirely.
- •An environment promotion step succeeds at PR time but fails at deploy time because the deployment job inherited write scopes from earlier build steps it never needed.
- •Security scanning flags the workflow because a job uses actions: with credentials passed via secrets, while a sibling job only needs contents: read to check out code.
- •The runner's effective token (repo: read, packages: write, id-token: write) does not match what the step code actually exercises, indicating stale declarations from a copied template.
Likely causes
- •A starter workflow or forked template declared permissions: write-all and was never tightened to per-job scopes.
- •Steps were refactored (e.g., moved from a release job back into a build job) but the permissions block still grants scopes the new job owner no longer requires.
- •The workflow relies on cross-job ${{ secrets.GITHUB_TOKEN }} inheritance without realizing each job resets to the workflow-level permissions unless explicitly overridden.
- •Organization-wide "Workflow permissions" is set to "Read repository contents and packages permissions" while individual workflows still declare write scopes, causing audit drift.
- •An OIDC id-token: write was added for one cloud deploy step but kept in the shared workflow header, leaking signing capability to unrelated jobs.
First ten minutes
- 01Open .github/workflows/*.yml and read the top-level permissions: block; note whether scopes are workflow-wide or scoped per job, and which keys are present (contents, packages, issues, pull-requests, deployments, id-token, attestations, checks, statuses).
- 02Grep every run: and uses: line across the workflow for API calls (curl, gh api, git push, npm publish, docker push, aws/gcloud/azure login steps) and map each to the minimum scope it exercises.
- 03Cross-check the Repository Settings -> Actions -> General -> "Workflow permissions" setting to see whether the repo is locked to read-only at the org level and whether your workflow currently complies.
- 04Run a workflow_dispatch on a throwaway branch against a real or staging environment, capturing the resolved token scopes shown at job start, and compare against the declared block.
- 05Inspect the most recent successful run's export token step (e.g., actions/github-script printing ${{ toJSON(github.token_permissions) }}) to enumerate what the runner actually received.
Evidence to collect
- •Diff between current .github/workflows/*.yml on the default branch and the last commit that touched the permissions: block, to spot broad grants inherited from a template.
- •A per-job permission table built by reading each job's steps and bucketing each API touchpoint into read/write/none for contents, packages, issues, pull-requests, deployments, id-token, checks, statuses, attestations.
- •The resolved GITHUB_TOKEN scopes printed by a github-script step in the target branch, timestamped within the last 24 hours.
- •A list of repository rules currently active under Settings -> Rules -> Rulesets that mention "Require approval for first-time contributors" or "Restrict workflow permissions", since tightening may trip those rules.
- •Environment protection rules (Settings -> Environments) and whether any deployment job requires a separate, narrower GITHUB_TOKEN scope than the workflow header provides.
Where to look
- •.github/workflows/*.yml on the default branch and any long-lived feature branches that share CI infrastructure.
- •The job header in the Actions run UI -> "Set up job" log block, which lists the GITHUB_TOKEN scopes the runner received for that job.
- •Settings -> Actions -> General -> "Workflow permissions" section, which controls whether repositories default to read-only or broad write.
- •Settings -> Rules -> Rulesets, looking for rules keyed on workflow file content or required status checks that gate changes to .github/workflows.
- •Settings -> Environments -> [environment-name] -> "Deployment branches and tags" and "Required reviewers" for promotion-time permission reconciliation.
- •The official workflow syntax reference (docs.github.com) for the canonical permissions: keys and their accepted values: read, write, none.
Diagnostic steps
- 01Build a per-job API surface table: list every step that calls GitHub APIs (gh api, REST endpoints via curl, git operations, package publishes), and tag each with the minimum token scope it strictly needs.
- 02Compare the table against the declared permissions: at workflow level and at each per-job override, and flag any scope marked "write" that no step in that job exercises.
- 03Decide the target permissions: block per job, preferring the narrowest accepted value (read, write, none) for each key the job actually touches, and explicit "permissions: read-all" only when intentional.
- 04Verify the OIDC id-token: write is confined to deployment jobs that perform cloud federation, not duplicated into build, lint, or test jobs.
- 05Confirm the workflow still survives Settings -> Actions -> General -> "Workflow permissions" policy if your organization enforces "Read repository contents and packages permissions" at the org level.
- 06Trigger the new workflow via workflow_dispatch on a non-protected branch, observe that the "Set up job" header lists only the narrowed scopes, and that every step still succeeds.
- 07If the workflow uses environments, push to the environment's protected branch and confirm environment-level secrets and required reviewers still resolve with the narrower token.
Common mistakes
- •Setting a workflow-wide permissions: write-all because a single deployment job needs write, instead of scoping that grant to the deployment job and leaving other jobs at contents: read.
- •Editing only the top-level permissions block while leaving per-job overrides intact, so the narrower declared values are silently masked by the broader job-level grant.
- •Leaving id-token: write in the shared workflow header after removing the federated cloud step, exposing an unused OIDC capability to every subsequent job.
- •Trusting the Actions UI "Set up job" header from one historical run instead of re-running, after the workflow file changed but the cached run UI still shows stale scopes.
- •Forgetting that permissions are also resolved against organization repository defaults, so tightening the YAML without adjusting the org-level setting leaves audit findings unchanged.
- •Adding permissions: {} (empty block) expecting "none", when the official syntax treats an empty map as the default org-wide setting rather than an explicit least-privilege value.
Safe fixes
- •Replace a broad workflow-level grant with an explicit per-job permissions: block; set keys to none for unrelated jobs, and to read or write only for jobs whose steps genuinely require them.
- •For pure read-only jobs (lint, labeler, issue triage), set permissions: contents: read and add pull-requests: read, issues: read, or checks: read only when those steps actually run.
- •Confine id-token: write to the single job that performs cloud federation (e.g., aws-actions/configure-aws-credentials with role-to-assume), and remove it from build or test siblings.
- •After tightening, open a PR against a branch not covered by required-reviewer rules so the change does not stall on self-approval, and watch the required checks that gate .github/workflows edits.
- •Update any reusable workflow (workflow_call) callers so their permissions: blocks remain compatible with the workflow they consume; the effective scope is the intersection of caller and callee.
- •If the org enforces read-only defaults, switch the repo setting to "Allow GitHub Actions to create and approve pull requests" or equivalent opt-in before merging a workflow that needs a narrow write scope, then re-narrow once merged.
Prove the fix
- 01The Actions run header for every job shows only the scopes the job's permissions: block declares; no extra write scopes appear, and id-token is absent from non-deployment jobs.
- 02A github-script guard step that runs actions/github-script with: - github.token_permissions - JSON.stringify(github) prints no scopes beyond the declared set, capturing this as part of the run artifact.
- 03A repository ruleset check such as "Restrict workflow permissions" passes on the new commit, and any security tool that flags broad token usage no longer reports this workflow.
- 04A workflow_dispatch rerun on the tightened branch completes green, with all release, package publish, and deploy steps still authenticating successfully through the narrowed GITHUB_TOKEN.
- 05If environments are involved, deploying to a protected environment still honors required reviewers and environment-scoped secrets under the reduced token, confirming the narrow grant is sufficient.
- 06A follow-up diff shows permissions: scopes unchanged in subsequent runs for at least one week, proving the tightened configuration is durable rather than a one-off revert.
Prevention and next steps
- •Add a CODEOWNERS entry for .github/workflows/ so reviewers must approve any permission change, and a ruleset requiring a status check named "Permissions audit" to pass before merge.
- •Adopt a starter-workflow convention that pre-declares per-job permissions: contents: read as the default and requires an explicit inline comment before any write or id-token scope is added.
- •Schedule a quarterly review that exports the resolved token scopes from a sentinel workflow and compares them against the declared YAML, alerting on drift.
- •Document, in the repository README's CI section, which jobs require which scopes and why, so contributors understand the least-privilege boundary before extending the pipeline.
Safe commands and checks
gh api repos/:owner/:repo/actions/permissions/workflow # view repo-level workflow permission policy (read-only)
gh api repos/:owner/:repo/actions/runs?per_page=5 --jq '.workflow_runs[] | {id, name, head_sha, event}' # enumerate recent runs to inspect scope drift
gh api repos/:owner/:repo/contents/.github/workflows/<file>.yml?ref=<branch> --jq '.content' | base64 -d # read workflow file as committed
gh workflow run <name>.yml --ref <branch> # trigger workflow_dispatch on a non-protected branch for verification
gh run view <run-id> --json jobs --jq '.jobs[] | {name, conclusion, url}' # confirm green status across narrowed jobs
gh api repos/:owner/:repo/rulesets --jq '.[] | {name, target, enforcement, conditions}' # audit rulesets gating .github/workflows changes (read-only)
gh api repos/:owner/:repo/environments --jq '.environments[] | {name, protection_rules: (.protection_rules | map(.type))}' # review environment protection rules before tightening deploys