Reference

How do permissions and secrets work 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.

GITHUB_TOKEN on a shield, beside contents read, issues write, packages none, and secrets.API_KEY.

🧭 The short answer

Every run is handed a real credential: a token GitHub mints for the job and revokes when the job ends. The permissions block names the scopes that token gets, and every scope it leaves out is off.

A secret is the other half. GitHub stores it encrypted, lends it to a run as secrets.NAME, masks the exact value in the log, and hands a pull request from a fork none of them.

🔑 GitHub token

  • It is a fresh token GitHub mints for each run, hands to the job, and revokes when the job ends. 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@v7
      - 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

  • It 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@v7
      - run: npm run build
  release:
    needs: build
    permissions:
      contents: write
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v7
      - 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

  • It 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@v7
      - 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

  • It is how a called workflow gets any of yours, since it starts with none. 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