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

  1. 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).
  2. 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.
  3. 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.
  4. 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.
  5. 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

  1. 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.
  2. 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.
  3. 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.
  4. 04Verify the OIDC id-token: write is confined to deployment jobs that perform cloud federation, not duplicated into build, lint, or test jobs.
  5. 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.
  6. 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.
  7. 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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