A release workflow needs the smallest set of GITHUB_TOKEN permissions that its publish job uses, usually contents read plus id-token write for trusted publishing. Declare a permissions block on the job, because any permission you list turns every unlisted one off.
Why this matters for publishers
Every action in a job can read the token through the github.token context, even when you do not pass it in. If a test step runs with write access, a compromised dependency in that step has write access too. GitHub's hardening guidance is to grant the minimum, set the repository default to read only for contents, and raise permissions only for the individual jobs that need them.
How the permissions key behaves
According to the workflow syntax reference:
- You can set permissions at the top of the workflow or inside a job.
- If you specify access for any permission, all unspecified ones are set to none.
- An empty permissions mapping disables all of them.
- Available names include contents, packages, attestations, id-token and pull-requests, among others.
- Without a block, the token starts at the enterprise, organization or repository default.
A split that works for most projects
permissions: {}
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- run: npm ci && npm test
publish:
needs: test
runs-on: ubuntu-latest
environment: release
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- run: npm publish
The test job cannot publish and cannot write to the repository. The publish job can ask for an OIDC token, which is what trusted publishing uses, and can read contents.
Permission by task
| Task in the workflow | Permission to add on that job |
|---|---|
| Check out code | contents: read |
| Trusted publishing to a registry | id-token: write |
| Build provenance attestation | id-token: write, attestations: write, contents: read |
| Push a container image to GitHub's registry | packages: write |
| Create a GitHub release | contents: write |
| Comment on a pull request | pull-requests: write |
The attestation row follows the artifact attestations guide, which lists id-token, contents and attestations for binaries and adds packages for container images.
Token lifetime and other facts
The token is a GitHub App installation token created at the start of each job, and it expires when the job finishes or at its maximum lifetime. On GitHub hosted runners that can be up to 6 hours. Events caused by the token generally do not start new workflow runs, with exceptions such as workflow_dispatch.
Checklist
- Put an empty permissions mapping at the top of each workflow, as in the example, or set the repository default to read.
- Add permissions per job.
- Keep id-token write off any job that runs untrusted code. See pull_request_target risks.
- Set persist-credentials to false on checkout.
- Review the workflow folder through CODEOWNERS.
Safeguard policies and gates can block merges that change workflow permissions in ways you did not approve; the free scan at gold.safeguard.sh/scan covers your dependencies.
Quick check
If I set only contents read, can the job still publish with OIDC?
No. Unspecified permissions become none, so id-token write must be listed.
Should id-token be set for the whole workflow?
Prefer the job level, so only the publish job can request an identity token.
Sources
- GitHub Docs: Workflow syntax, permissions
- GitHub Docs: Secure use reference
- GitHub Docs: GITHUB_TOKEN concepts
- GitHub Docs: Artifact attestations
Last reviewed: 19 October 2026.