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.
How StarSling worksAI agents open the PR
StarSling agents inspect your workflow, apply this optimization, and open a reviewable PR automatically.
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
Better Auth, browser-use, FastAPI, and Vite keep OIDC token minting scoped to the jobs that actually publish or release.
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 # ...
Shipped by StarSling
On Mastra, a StarSling agent moved id-token: write off the workflow default and kept OIDC token minting on the publish jobs only.
- Read the Mastra customer storyMerged 2026-05-12
Customer story
ci(security): scope id-token: write to publish jobs only
StarSling moved id-token: write out of workflow scope so only publish jobs can mint an OIDC token.
Read the Mastra customer storyView PR #16509 in mastra-ai/mastra (opens in new tab)
Write tokens on untrusted triggers: the same rule
id-token is not the only permission that leaks by sitting at the wrong scope. A workflow that grants write permissions on an event an outsider can trigger (pull_request_target, issue_comment, workflow_run, pull_request_review) hands attacker-controlled activity a write-capable token: enough to forge commits, approve pull requests, or dispatch a release. The elementary-data incident chained exactly this pivot: a default write token plus script injection forged a github-actions[bot] commit, dispatched the release workflow, and published a malicious package to PyPI in ten minutes. The fix is the shape this page already teaches: keep the top-level permissions: read-only and grant contents: write or pull-requests: write only in a job that never runs on an untrusted trigger, typically by splitting the workflow in two: an unprivileged pull_request job that uploads an artifact, and a separate workflow_run job that holds the write token and treats that artifact strictly as data (a comment body, a status value), never as something to execute or publish, because the artifact is still attacker-controlled input. GitHub shipped workflow-trigger policies in June 2026, but they are opt-in and evaluate-mode, so nothing changes on default configuration. A write token on an untrusted trigger is one of the ten attack vectors the ci-secure skill scans workflows for.
Runs on your machine. GitHub API optional, for the impostor-SHA check and the dormancy note.
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.
Go further
One fix, all of them, or forever.
You have the prompt for this one practice. Here is how much further you can take it, each step doing more for you than the last.
Fix this one thing
Copy the prompt above
Hand P14.8 to your coding agent and fix it in your repo today.
Fix everything, once
Install the ci-score skill
One prompt grades your whole workflow config against all 11 CI Score checks, this one included, and hands your agent a ranked fix for every gap it finds. Open source, runs locally. It grades configuration, not speed.
Keep it fixed, forever
Install the StarSling GitHub App
Connect GitHub and the fixes stay applied as your CI evolves, with agents that keep inspecting your workflows and opening optimization PRs you review.
More best practices for GitHub Actions
Where to go next in the CI best-practices catalog.
- Security
Pin GitHub Actions to commit SHAs
Reference every third-party action by its full 40-character commit SHA (with the version as a trailing comment), not a mutable
@v4tag or@mainbranch that its maintainer, or an attacker, can re-point. - Security
GitHub Actions permissions: use least privilege
Set a read-only top-level
permissions:default in every GitHub Actions workflow, then grant each job only the additionalGITHUB_TOKENscopes it needs, so unrelated steps cannot inherit repository write access. - Security
GitHub Actions secrets: prevent leaks and overexposure
Expose a GitHub Actions secret only to the step that consumes it, gate production secrets with an environment, prefer short-lived OIDC credentials, and keep credentials out of logs, command arguments, caches, and artifacts.
More ways to improve GitHub Actions
If you do not know why a run is slow yet, start with the diagnostic guide. Let an agent find which of these applies: /ci-speedup. If your workflows are already optimized but still slow, see our guide to fast GitHub Actions and GitHub Actions runner alternatives. Building containers in CI? The Docker workflow guide covers layer caching end to end. Want to see how your configuration measures up before changing anything? CI Score grades workflow config against a pass/fail rubric of these practices. It is not a speed measurement. To find exploitable workflow paths before attackers do, ci-secure checks the ten critical GitHub Actions attack vectors and lets you choose which fixes to apply.
- GitHub Actions too slow
- Fast GitHub Actions
- GitHub Actions alternatives
- GitHub Actions pricing
- Self-hosted GitHub Actions runners
- Docker CI on GitHub Actions
- Install the free /ci-score skill to improve your GitHub Actions setup
- Install the /ci-secure skill to close critical attack vectors in GitHub Actions
- Browse GitHub Actions agent skills
FAQ
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-08-19