Permissions and secrets in GitHub Actions
The token every run carries, the scopes it gets, and where the other credentials live. Every scope in a table, the three levels and what unset means, and the rules that turn a workflow into somebody else's key to your repository.
Every run is handed a real credential. This page is what that token may reach, how to narrow it to the job that needs it, and where your secrets live.
🔑 GitHub token
GitHub mints a fresh token for each run, hands it to the job, and revokes it when the job ends. It is a real credential with real reach.
- Reach for it when the run talks to this repository: a checkout, a comment on a pull request, a label, a release, a push.
- Lives in
secrets.GITHUB_TOKEN, orgithub.token, which are the same value.actions/checkouttakes it without being asked, andghreads it fromGH_TOKEN.
on: pull_request
permissions:
contents: read
pull-requests: write
jobs:
comment:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR: ${{ github.event.number }}
run: gh pr comment "$PR" --body "Looked at it."
Gets you in trouble when
- It is not you. Everything it does is done by
github-actions[bot], so a commit it pushes is not yours and a review it leaves cannot approve your own pull request. - A push it makes starts nothing. Events raised with this token do not trigger another workflow, on purpose, so a tag it pushes never builds; that needs an App token or a personal one, and the loop guard goes with it.
- Nobody set the default. A workflow granting nothing gets the repository default, set under Settings, Actions, Workflow permissions, and on an older repository that is read and write on everything.
- A pull request from a fork gets a read-only token whatever
permissionssays, and no secrets at all, so the job that needs one has to fail cleanly. - You put it somewhere it is printed. It is masked in a log like any secret, and masking only ever matches the exact value.
In the editor the shield card, What it is allowed to touch, opens with a sentence saying what the token may do as the file is written.
🛡️ permissions
permissions is the list of scopes that token gets. Write it on the workflow, where it covers every job, or on a job, where it covers only that one.
- Reach for it when always, on every workflow you write. It is one key, it takes a minute, and it is the difference between a bug and an incident.
- Lives at the top level, or on a job, where it replaces the workflow's mapping whole rather than adding to it.
read-all,write-all, and{}are the shorthands.
on: push
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- run: npm run build
release:
needs: build
permissions:
contents: write
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release create "$GITHUB_REF_NAME" --generate-notes
Least privilege, said once: start from nothing, add the one scope the job actually needs, and write it on the job rather than the whole workflow.
Every scope GitHub offers, and what a workflow that files issues and edits its own code tends to need:
| Scope | What it lets the run do | A typical agent workflow needs |
|---|---|---|
actions |
Read runs, logs, and artifacts; write cancels, re-runs, and starts a workflow | read, or write where it dispatches another workflow |
artifact-metadata |
Read what an artifact is and who made it, without downloading it | Nothing |
attestations |
Read build provenance; write signs a new attestation for what it built | Nothing |
checks |
Read check runs; write creates one and reports a result on a commit | Nothing, unless it reports its own check |
code-quality |
Read the repository's code quality results | Nothing |
contents |
Read the code and files; write pushes commits, branches, tags, and releases | read to check out, write only on the job that commits |
deployments |
Read deployments; write creates one and moves its status | Nothing, unless it deploys |
discussions |
Read discussions; write posts and edits them | Nothing |
id-token |
Fetch an OIDC token, the thing that logs into a cloud without a stored key | write only where it signs into a cloud provider |
issues |
Read issues and comments; write opens, labels, comments, and closes them | write, which is most of what an agent workflow does |
models |
Call the GitHub Models inference API; read is the only grant it takes | Nothing, unless it uses GitHub Models |
packages |
Read packages from the registry; write publishes one | Nothing, unless it publishes |
pages |
Read the Pages site; write requests a build and a deployment | Nothing, unless it deploys Pages |
pull-requests |
Read pull requests; write opens, labels, comments, and reviews them | write, on the job that raises the pull request |
repository-projects |
Read project boards; write moves cards around them | Nothing |
security-events |
Read code scanning alerts; write uploads a SARIF result | Nothing, unless it runs a scanner |
statuses |
Read commit statuses; write sets one on a commit | Nothing |
vulnerability-alerts |
Read Dependabot alerts; read is the only grant it takes | Nothing |
copilot-requests |
Spend the repository's Copilot request allowance; write is the only grant it takes | Nothing |
Three levels, and one of them is the absence of the key:
read: look, do not touch. It is the level almost everything wants, andcontents: readis what a checkout needs.write: read and write both. There is no write-only level, so granting a write always grants the read with it.none: nothing at all on that scope, and the same as leaving the scope out of a mapping you have written.- Unset on the workflow means the repository decides, under Settings, Actions, Workflow permissions.
- Unset on a job means the workflow above it decides, and if nothing is set there either, the repository does.
Gets you in trouble when
- You delete the key instead of emptying it. Writing an empty mapping,
permissions: {}, grants nothing; removingpermissionsaltogether hands the job the repository default, which is usually far wider. - A job's mapping replaces the workflow's, whole. It is never merged, so a job naming
issues: writealone has nocontentsat all and its checkout of a private repository fails. - You reach for
write-allto make a red run go green. It grants every scope on the list, the code included, to every step, including the third-party action three steps down. - Not every scope takes every level.
modelsandvulnerability-alertstakereadornone,copilot-requeststakeswrite, and the editor's select offers only what each one allows. - Nothing is set and a step pushes. The editor says so: neither the workflow nor the job sets
permissions, so the token carries the repository default, whatever that turns out to be.
In the editor the shield card's presets write the usual answers in a click, and Scope by scope gives each grant its own row, with the ungranted below as chips.
🔐 secrets
A secret is a value GitHub stores encrypted and lends to a run: an API key, a deploy key, a token for somewhere that is not GitHub.
- Reach for it when a step needs a credential the run's own token is not, and the value must not appear in the file, the history, or a fork.
- Lives in repository, environment, or organisation settings, and is read as
secrets.NAMEinside${{ }}. The file names it; the file never holds it.
on: workflow_dispatch
permissions:
contents: read
jobs:
ship:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: ./ship.sh
The rules, all of them:
- Set one under Settings, Secrets and variables, Actions. An organisation secret reaches every repository it is shared with; an environment secret reaches only a job that names that environment.
- Read one in
env, inwith, or in thesecretsa called workflow is handed. Never in anif, on a job or a step: GitHub keeps the context out of every condition. - Never in a
runline. Put it inenvon the step and read$NAMEin the script instead; the shell gets the value, and the command line does not. - A name cannot start with
GITHUB_, and once saved a secret can be replaced but never read back, in the UI or by you. - A variable is not a secret.
vars.NAMEis for the things you would not mind reading in a public log; anything else is a secret.
Gets you in trouble when
- It lands in a
runline anyway. The editor names it as you type:The script interpolates secrets.API_KEY, so the secret lands in the command line and in the log of a failed command. - Something prints it in another shape. GitHub masks the exact value and nothing else, so a base64 of it, a URL holding it, or a JSON dump of the environment goes through in plain text. Masking is a safety net, not a plan.
- A
pull_request_targetworkflow checks out the fork's branch. That runs a stranger's code with your secrets and your write token, in your repository; the editor warns on exactly that step. - The pull request came from a fork. There are no secrets at all there, so
secrets.API_KEYreads as empty text and the step fails somewhere further down, wearing a confusing error. - A secret is printed once. Then it is a rotated secret, not a masked one: revoke it at the place that issued it, and put the new one in GitHub.
In the editor the findings panel flags a secrets. value written into a run line, and a pull_request_target checkout of the fork's head, on load and on every keystroke.
🤝 secrets inherit
A called workflow starts with none of yours. secrets: inherit hands it every secret this workflow can see; a mapping hands it only the ones you name.
- Reach for it when a job calls a reusable workflow that needs a credential to do its half of the work.
- Lives on a calling job, beside
usesandwith, as either the wordinheritor a mapping of the called workflow's names to values.
on: push
permissions:
contents: read
jobs:
deploy:
uses: ./.github/workflows/deploy.yml
secrets:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
smoke:
uses: ./.github/workflows/smoke.yml
secrets: inherit
Naming them is the safe default and inherit is the convenient one:
- Naming secrets hands over exactly those. The called workflow has to declare each under
on.workflow_call.secrets, or GitHub refuses the run before anything starts. inherithands over the lot: every repository, environment, and organisation secret this workflow can read, with no list to review.- A call still runs under the caller's token, so the called workflow's
permissionscan narrow what it may do but never widen it.
Gets you in trouble when
inheritpoints at a repository you do not own. A reusable workflow elsewhere is somebody else's code, and one line just gave it every credential you have.- You take the last named secret out. The
secretskey stays written as an empty mapping, which is neitherinheritnor nothing: it hands over exactly no secrets, quietly. - A name does not match. The mapping's keys are the called workflow's names, not yours, so
DEPLOY_KEY: ${{ secrets.PROD_DEPLOY_KEY }}is the usual shape and a typo on the left fails the run. - The called workflow needs
contents: writeand the caller grantedread. The call inherits the narrower token, so the grant has to be made here, on the calling job. - You reach for
inheritto make a failing call pass. It works, and it hands over nineteen credentials to fix one; name the one it was missing instead.
In the editor a calling job's Secrets it hands over offers Nothing, Only the ones I name, and Every secret this workflow holds, saying what each hands over.
📚 Straight from GitHub
permissions: every scope, the three levels, and theread-allandwrite-allshorthands.- Automatic token authentication: where
GITHUB_TOKENcomes from, what it defaults to, and why a push it makes starts no workflow. - Using secrets in GitHub Actions: setting one, reading one, the naming rules, and the limits.
- Security hardening for GitHub Actions: masking, third-party actions, and script injection in a
runline. jobs.<job_id>.secretsandinherit: handing secrets to a called workflow.pull_request_target: the trigger that runs your workflow, with your secrets, over a fork's pull request.