In GitHub Actions, what does the permissions key do to the GITHUB_TOKEN?
answer
- it scopes the automatically-provided job token
- think of it as a whitelist, not a patch
- unlisted scopes do not stay at the default
- job block replaces, it does not merge
- one scope is not about the repo API at all
basics
~20 sIt sets the API scopes the job's GITHUB_TOKEN carries. Listing any scope drops every scope you did not list to none, so permissions: contents: read yields a token that can clone but cannot write issues, packages, or code.
solid answer
~40 s`permissions:` declares, per workflow or per job, what the automatically-provided `GITHUB_TOKEN` may do against the repository's API. It takes a map of scopes such as `contents`, `pull-requests`, `issues`, `packages`, `actions`, `checks`, `deployments`, `security-events`, and `id-token`, each set to `read`, `write`, or `none`; the shorthands `read-all`, `write-all`, and the empty map `{}` set everything at once. The key mechanic interviewers want is that the map is **exhaustive, not additive**: the moment you specify one scope, all unspecified scopes become `none`. A job-level block replaces the workflow-level block for that job rather than merging with it, so the usual pattern is a restrictive workflow-level default with one job widening exactly what it needs. Independently of what you write, fork pull-request runs cap the token at read-only.
code
yaml · 24 linesname: ci
on: pull_request
permissions:
contents: read # default for every job in this workflow
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: ./gradlew build
comment:
needs: build
runs-on: ubuntu-latest
permissions:
contents: read # restated: the job block replaces, it does not merge
pull-requests: write
steps:
- run: gh pr comment "$NUMBER" --body "build ok"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NUMBER: ${{ github.event.number }}go deeper
Recognise the permissions: block in a workflow and know it limits what the job's token may do. Remember that contents: read is what actions/checkout needs on a private repository.
Explain the exhaustive rule — naming one scope zeroes the rest — and that a job-level block replaces the workflow-level default rather than merging into it. Name several real scopes.
Show how you diagnose Resource not accessible by integration by adding the single missing scope, and describe the standard layout: restrictive workflow default, narrowly widened jobs, id-token: write only where OIDC runs.
Own the standard across repositories: least-privilege defaults, the organization workflow-permissions baseline, when a GitHub App token replaces GITHUB_TOKEN for cross-repository work, and how those choices are enforced and reviewed.
## What the key controls Every job gets an installation token exposed as `secrets.GITHUB_TOKEN` and as `github.token`. The `permissions:` key decides which API scopes that token carries for the run. It can be written at the top level of the workflow (a default for all jobs) or inside a single job (which replaces the default for that job). permissions: contents: read pull-requests: write Each scope takes `read`, `write`, or `none`. `write` implies read. Commonly named scopes include `actions`, `attestations`, `checks`, `contents`, `deployments`, `discussions`, `id-token`, `issues`, `packages`, `pages`, `pull-requests`, `security-events`, and `statuses`. ## The exhaustive rule This is the part that surprises people. `permissions:` is not a delta applied on top of the defaults — **naming any scope sets every scope you did not name to `none`.** So this job's token can read the repository and nothing else: permissions: contents: read It cannot comment on a pull request, publish a package, or upload a SARIF result. That is the feature: you cannot accidentally over-grant by forgetting to deny something, only by explicitly granting it. It is also the usual cause of a sudden `Resource not accessible by integration` error after someone tightened a workflow — the fix is to add the one scope the failing API call needs, not to revert to `write-all`. Three shorthands exist. `permissions: read-all` and `permissions: write-all` set every scope to read or write. `permissions: {}` — an empty map — sets every scope to `none`, producing a token that can do essentially nothing against the API, which is the right default for a job that only builds and tests code it already checked out. ## Workflow level versus job level A job-level `permissions:` block **replaces** the workflow-level one for that job; the two are not merged. The idiomatic layout is therefore a tight default at the top and a widened block on the one job that needs more: permissions: contents: read # default for every job jobs: build: { ... } # inherits contents: read only comment: permissions: contents: read pull-requests: write # must restate contents: read steps: [ ... ] Note that `contents: read` has to be repeated in the job block: because the block replaces rather than merges, omitting it would leave the job unable to check out a private repository. ## Interaction with repository and organization settings Repositories and organizations have a default workflow-permissions setting (read-only or read/write) that applies when a workflow says nothing. Newer repositories default to read-only. There are also separate settings governing whether Actions may create or approve pull requests. Because those settings only establish the baseline, a well-run repository still writes `permissions:` explicitly in each workflow: the file then documents its own blast radius and does not change meaning when an admin flips a setting. Two things the key cannot do: it cannot grant access to another repository (the token is scoped to the repository running the workflow — cross-repository work needs a GitHub App token or a PAT), and it cannot lift the cap on fork pull-request runs, where the token is read-only and secrets are unavailable regardless of what the workflow requests. ## Where `id-token` fits `id-token: write` is unusual: it does not grant repository API access at all. It permits the job to request a short-lived OIDC token from GitHub's identity provider, which is then exchanged for cloud credentials. It is required for OIDC-based cloud login and, because it is a scope like any other, it is silently `none` unless you list it — the most common cause of `Unable to get ACTIONS_ID_TOKEN_REQUEST_URL` failures. ## Answering well Say what it is (per-run token scoping), state the exhaustive rule, mention job-level replacement, and give the concrete default you use: `permissions: contents: read` at the top of every workflow, widened only where a step genuinely calls a write API.
- A workflow adds permissions: pull-requests: write and its checkout of a private repo starts failing. Why?Because the map is exhaustive: naming `pull-requests` set every other scope, including `contents`, to `none`, and `actions/checkout` needs `contents: read` to fetch a private repository. The fix is to list both scopes in that block, not to fall back to `write-all`.
- What does permissions: {} give a job, and when is it the right default?An empty map sets every scope to `none`, so the `GITHUB_TOKEN` can perform essentially no repository API operation. It suits jobs that only run a build or test against code fetched from a public repository or from a previous job's artifact, and it makes any accidental API call fail loudly rather than succeed with unnecessary rights.
- Can the permissions key give a job access to a second repository?No. The `GITHUB_TOKEN` is an installation token scoped to the repository that owns the workflow run, so no scope combination reaches another repository. Cross-repository automation needs a different identity — typically a GitHub App installation token minted in the job, which is preferable to a personal access token because it is short-lived and scoped.
saying these in an interview costs you the question
- Believes unlisted scopes keep their default value
- Uses write-all to fix a Resource not accessible error
- Thinks job-level permissions merge with workflow-level
- Expects the token to reach other repositories
- Forgets id-token: write when wiring OIDC