Scope id-token: write to the publishing job
In GitHub Actions, declare `id-token: write` in the specific publishing job's `permissions:` block, never at the workflow's top level, so an OIDC publish token isn't minted for jobs that run untrusted code.
Do this
The workflow's top-level permissions: grants no id-token. Only the publish job requests id-token: write, ideally behind a protected environment. Jobs that run tests or install dependencies, where untrusted third-party code executes, never have a publish-capable token in memory to steal.123
permissions:
contents: read # workflow default: no id-token
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read # explicit: no OIDC token minted here
steps:
- uses: actions/checkout@v7
- run: npm test
publish:
needs: test
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # scoped to the publish job only
environment: release
steps:
- uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0Avoid this
Any job, including untrusted test code, can read the publish token out of runner memory.
permissions:
contents: read
id-token: write # a publish token exists for every job and every step
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: npm test # runs untrusted dep code with the token in memory
publish:
needs: test
runs-on: ubuntu-latest
steps:
- uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0Seen in the wild
Real workflow files from public projects.
build_publish_image:
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
attestations: write
id-token: write if: github.repository == 'vitejs/vite'
runs-on: ubuntu-latest
permissions:
contents: read
id-token: writepermissions: {}
# ...
publish:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: readnon-adjacent lines joined by # ...
permissions: {}
# ...
environment: Release
permissions:
contents: write
pull-requests: write
id-token: writenon-adjacent lines joined by # ...
Why it matters
When id-token: write sits at the workflow level, the runner mints a publish-capable OIDC token for the entire run, including the test job that executes untrusted dependency code. Any code that runs before the real publish step can read that token out of runner memory and publish a malicious package. This was the third leg of the TanStack 2026 supply-chain compromise: a poisoned cache dumped runner memory and used the workflow-level OIDC token to publish 84 malicious versions. Per-job scoping shrinks the window during which the token exists.
When to use
Use it when
Any workflow that uses OIDC trusted publishing (npm, PyPI, cloud deploys) and also runs tests, linting, or dependency installs in the same run.
Be careful when
Per-job scoping limits blast radius across jobs, not within the publish job itself, malware restored into the publish job's own cache can still read its token. Combine with cache-integrity hygiene, and where feasible split publishing into its own trigger-gated workflow.
Verify on your repo
Hand this prompt to your coding agent (Claude Code, Cursor, and the like) to audit and fix this practice in your own repo.
Inspect this repo's .github/workflows for `id-token: write` declared in a top-level `permissions:` block, which mints an OIDC publish token for every job including ones that run untrusted test or dependency code. Move `id-token: write` out of the workflow level and into only the specific publishing job's `permissions:` block, and leave the other jobs with no `id-token` (or an explicit `contents: read`). Do not touch hardened spellings like `id-token: none` or `id-token: read` elsewhere: only the workflow-level `write` is the finding. Show me the diff and open a PR rather than applying it blindly.
Ground these changes in the upstream docs before you edit: https://docs.github.com/en/actions/reference/security/oidc. If you cannot fetch them, say so rather than guessing, and cite what you used in the PR description.Prefer to check by hand?
Check whether
id-token: writeappears in a top-levelpermissions:block:grep -rn 'id-token' .github/workflows/.If it's at workflow scope, move it into the publish job's
permissions:and leave other jobs with noid-tokendeclaration.Confirm hardened spellings elsewhere (
id-token: none/read) are untouched, only the workflow-levelwriteis the finding.
Automate this with StarSling.
Rather than audit this by hand, install the StarSling GitHub App: it opens a pull request to move your workflows onto StarSling runners, then its agents keep reading your jobs, run logs, and machine telemetry and open pull requests that fix what this page covers, along with caching, parallelization, and test sharding across the rest of your pipeline. Every change arrives as a PR you review and merge.
More best practices for GitHub Actions
Where to go next in the CI best-practices catalog.
All CI best practicesFAQ
Does per-job scoping fully protect the publish token?
No, it limits blast radius across jobs, not within the publish job. If malware is restored into the publish job's own cache, it can still read the token there. Scope per job as a baseline, keep untrusted code out of the publish job, and pair it with cache-integrity checks.
Why not just remove id-token entirely?
OIDC trusted publishing needs id-token: write in the job that publishes. The goal isn't to remove it but to confine it, grant it only where the publish happens, not to every job in the run.
Sources
1GitHub · OIDC reference: scoping id-token to one job (opens in new tab)
2GitHub · OpenID Connect (OIDC) for Actions (opens in new tab)
3GitHub · security hardening for GitHub Actions (opens in new tab)
Last updated 2026-07-15
Only give the publish token to the publish job.
One line to install. Faster runs on day one, and agents that open reviewable PRs to keep your pipeline following practices like this one.