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 works

AI 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

id-token scoped to the publish job
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.0

Avoid this

Any job, including untrusted test code, can read the publish token out of runner memory.

id-token at workflow scope: every job gets it
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.0

Seen 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
Four narrowly scoped permissions on the publish job, id-token among them, and none declared at the workflow level.
    if: github.repository == 'vitejs/vite'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
id-token: write is granted on the publish job. The workflow declares no top-level permissions block, so no other job can mint a token.
permissions: {}
# ...
  publish:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read

non-adjacent lines joined by # ...

The workflow denies everything with a top-level permissions: {}, then the publish job alone requests id-token: write for trusted publishing to PyPI.
permissions: {}
# ...
    environment: Release
    permissions:
      contents: write
      pull-requests: write
      id-token: write

non-adjacent lines joined by # ...

The release job opts into exactly three permissions -- contents and pull-requests to raise the version PR, and id-token to publish -- while the workflow's top-level default grants nothing at all.

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.

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.

Prompt for your coding agent
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?

  1. Check whether id-token: write appears in a top-level permissions: block: grep -rn 'id-token' .github/workflows/.

  2. If it's at workflow scope, move it into the publish job's permissions: and leave other jobs with no id-token declaration.

  3. Confirm hardened spellings elsewhere (id-token: none/read) are untouched, only the workflow-level write is 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.

  1. Fix this one thing

    Copy the prompt above

    Hand P14.8 to your coding agent and fix it in your repo today.

  2. 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.

  3. 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.

All GitHub Actions best practices

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.

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