skip to content

In GitHub Actions, what does the permissions key do to the GITHUB_TOKEN?

level: middleimportance: must knowfreq 70%

answer

  1. it scopes the automatically-provided job token
  2. think of it as a whitelist, not a patch
  3. unlisted scopes do not stay at the default
  4. job block replaces, it does not merge
  5. one scope is not about the repo API at all

basics

~20 s

It 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 lines
yaml
name: 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

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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

context