Limiting workflow permissions per job Jump to heading

The token a pipeline job receives is a credential for your repository, and by default it frequently carries far more than the job needs. That matters because a job runs code β€” your code, your dependencies’ install scripts, and whatever a third-party action does β€” and any of those can use the token it was given. Denying everything by default and granting per job turns a compromise of one job into a small problem instead of a repository-wide one. This recipe applies it, within repository access control and policy.

When to use this approach Jump to heading

  • Workflows do not declare permissions at all, so they inherit the default.
  • A single workflow contains both a test job and a publishing job.
  • Third-party actions run in jobs that hold write permissions.
  • An audit asked what a compromised dependency could do during a build.
  • If every workflow already declares empty permissions and grants per job, verify with Step 5 and stop.

Step 1 β€” Find out what the default currently grants Jump to heading

gh api repos/:owner/:repo/actions/permissions/workflow \
  --jq '{default: .default_workflow_permissions, can_approve: .can_approve_pull_request_reviews}'
# Which workflows declare nothing, and therefore inherit it?
grep -L 'permissions:' .github/workflows/*.yml
# Verification: print what a run actually received
# (add temporarily to a job)
#   - run: gh api repos/${{ github.repository }} --silent && echo "token can read the repo"
What an unconstrained pipeline token can reachA default write token grants contents, packages, issues, pull requests, deployments and more. A typical test job needs one of those. Every additional scope is capability available to any code the job happens to run.token scopes granteddefault write token11build and publish job needs3test job needs1the gap between the first and third bars is what a compromised dependency inherits

Step 2 β€” Deny at the workflow level Jump to heading

# .github/workflows/ci.yml
name: ci
on: [pull_request, push]

permissions: {}          # nothing, unless a job asks

jobs:
  test:
    permissions:
      contents: read     # checkout needs this, and nothing else
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci --ignore-scripts
      - run: npm test
# Verification: the job fails if it tries to do more
#   - run: gh api -X POST repos/${{ github.repository }}/issues -f title=probe
#     β†’ 403, which is the intended outcome
# Apply the same default at the repository level
gh api -X PUT repos/:owner/:repo/actions/permissions/workflow \
  -f default_workflow_permissions=read -F can_approve_pull_request_reviews=false

can_approve_pull_request_reviews=false closes a specific hole: with it enabled, a workflow can approve a pull request, which means a required review can be satisfied by something that is not a person.

Step 3 β€” Grant narrowly, per job Jump to heading

jobs:
  test:
    permissions: { contents: read }

  comment:
    permissions:
      contents: read
      pull-requests: write        # only this job can comment

  publish:
    needs: [test]
    permissions:
      contents: read
      packages: write
      id-token: write             # only this job can sign
# Verification: each job's permissions match its actual steps
awk '/^  [a-z-]+:/{job=$1} /permissions:/{print job, $0}' .github/workflows/ci.yml
Three jobs, three permission setsThe test job reads the repository and nothing more. The commenting job adds write access to pull requests. The publishing job adds package and identity scopes. A compromise of the first reaches only what the first could do.testcontents: readno write anywherecommentpull-requests: writeno packagespublishpackages: writeid-token: writethe blast radius of a compromised step is the permissions of its own job

Step 4 β€” Keep untrusted code out of privileged jobs Jump to heading

Permissions limit what a job can do; what runs in the job decides whether that matters.

  publish:
    permissions: { contents: read, packages: write, id-token: write }
    steps:
      - uses: actions/checkout@v4
      - run: npm ci --ignore-scripts        # no lifecycle scripts in a privileged job
      - run: npm publish
# Verification: the privileged job runs no third-party action not pinned by digest
grep -A20 '^  publish:' .github/workflows/ci.yml | grep 'uses:' | grep -v '@[0-9a-f]\{40\}'

SAFETY WARNING β€” id-token: write lets a job obtain a token proving it is your workflow, which is what keyless signing depends on. A job holding it that also runs untrusted code β€” a lifecycle script from an unreviewed dependency, an unpinned action, code from a fork β€” can be induced to sign something an attacker chose. Grant it only to a job that runs code you have reviewed, and never combine it with a trigger that runs on unreviewed contributions.

Step 5 β€” Verify what each job could actually do Jump to heading

Declared permissions and effective permissions can differ, particularly across reusable workflows.

# The permissions a run actually received, from the API
gh api "repos/:owner/:repo/actions/runs/$RUN_ID/jobs" --jq '.jobs[] | .name'
# A probe step, temporarily, in each job
      - name: What can this token do?
        run: |
          for ep in issues pulls actions/secrets; do
            code=$(gh api "repos/${{ github.repository }}/$ep" -q '.' -i 2>/dev/null | head -1)
            printf '%-20s %s\n' "$ep" "$code"
          done
        env: { GH_TOKEN: "${{ github.token }}" }
# Verification: reusable workflows inherit rather than widen
grep -rn 'uses: .*\.github/workflows/' .github/workflows/ | head

Reusable workflows are the usual source of surprise: a called workflow receives the permissions the caller grants, and a caller that grants broadly hands that to everything it calls. Declaring permissions on the called workflow as well makes the intent explicit in both places.

Narrowing from default to per jobStart by finding what the default grants, deny everything at the workflow level, grant each job only what its steps use, keep untrusted code out of the jobs that hold write scopes, and verify what each token could actually reach.Measurewhat the default grantsDenypermissions: {}Grant per jobonly what is usedIsolateno untrusted codein privileged jobsVerifyprobe each tokenthe fourth box is what makes the third one worth doing

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Will denying by default break existing workflows? Jump to heading

Some, and the failures are immediate and clear: a step that needed a scope gets a 403 naming the endpoint. Roll it out one workflow at a time, watch the first few runs, and grant what the failures actually require rather than what seems plausible. The whole exercise is usually an afternoon.

What about workflows triggered by forks? Jump to heading

They receive a read-only token regardless of what the workflow declares, which is the correct default and the reason reporting from a fork build has to use the run summary rather than a comment. Attempting to work around it with an elevated trigger is the anti-pattern described in scanning pull requests for secrets in CI.

Do these permissions apply to secrets as well? Jump to heading

No β€” secrets are controlled separately and a job that can read a secret can use it regardless of token scopes. Narrow both: scope the token per job and restrict which environments or jobs can access which secrets, because the token permissions say nothing about what a leaked deployment credential in the same job could do.