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
permissionsat 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" 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 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: writelets 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.
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.
Related Jump to heading
- Repository Access Control & Policy β the parent topic and the credential routes.
- Scoping Deploy Keys and Tokens β the same discipline for credentials outside the pipeline.
- Pinning GitHub Actions to a Commit SHA β controlling what runs inside a privileged job.