Knowledgebase

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, or github.token, which are the same value. actions/checkout takes it without being asked, and gh reads it from GH_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 permissions says, 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, and contents: read is 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; removing permissions altogether 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: write alone has no contents at all and its checkout of a private repository fails.
  • You reach for write-all to 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. models and vulnerability-alerts take read or none, copilot-requests takes write, 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.NAME inside ${{ }}. 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, in with, or in the secrets a called workflow is handed. Never in an if, on a job or a step: GitHub keeps the context out of every condition.
  • Never in a run line. Put it in env on the step and read $NAME in 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.NAME is 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 run line 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_target workflow 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_KEY reads 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 uses and with, as either the word inherit or 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.
  • inherit hands 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 permissions can narrow what it may do but never widen it.

Gets you in trouble when

  • inherit points 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 secrets key stays written as an empty mapping, which is neither inherit nor 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: write and the caller granted read. The call inherits the narrower token, so the grant has to be made here, on the calling job.
  • You reach for inherit to 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