---
title: "GitHub Actions: scope id-token: write to the publish job"
description: "Declaring id-token: write at the workflow level mints an OIDC publish token for every job. Scope it to the publish job only, with copyable YAML."
url: https://starsling.dev/best-practices/github-actions/scope-id-token-per-job
canonicalUrl: https://starsling.dev/best-practices/github-actions/scope-id-token-per-job
---

# Scope id-token: write to the publishing job

[GitHub Actions best practices](https://starsling.dev/best-practices/github-actions) / Scope id-token: write to the publishing job

- [How StarSling works](https://starsling.dev/)

Best practice: Security. Last updated: 2026-10-06

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.

## Table of contents

- [Do this](#do-this)
- [Avoid this](#avoid-this)
- [Seen in the wild](#seen-in-the-wild)
- [Shipped by StarSling](#shipped-by-starsling)
- [Write tokens on untrusted triggers: the same rule](#write-token-on-untrusted-trigger)
- [Why it matters](#why-it-matters)
- [When to use](#when-to-use)
- [Verify on your repo](#verify)
- [More ways to improve GitHub Actions](#speedup-guides)
- [Sources](#sources)

<a id="do-this"></a>

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

_id-token scoped to the publish job_

```yaml
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
```

<a id="avoid-this"></a>

## 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_

```yaml
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
```

<a id="seen-in-the-wild"></a>

## Seen in the wild

Better Auth, browser-use, FastAPI, and Vite keep OIDC token minting scoped to the jobs that actually publish or release.

### browser-use/browser-use - .github/workflows/docker.yml

```yaml
  build_publish_image:
    runs-on: ubuntu-latest
    permissions:
      packages: write
      contents: read
      attestations: write
      id-token: write
```

Key line: `id-token: write`.

Four narrowly scoped permissions on the publish job, id-token among them, and none declared at the workflow level.

Source: [browser-use/browser-use `.github/workflows/docker.yml` lines 21-27](https://github.com/browser-use/browser-use/blob/f78585575905b11692186783c770f5f3f2feeb9a/.github/workflows/docker.yml#L21-L27) at commit `f785855`.

### vitejs/vite - .github/workflows/publish.yml

```yaml
    if: github.repository == 'vitejs/vite'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
```

Key line: `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.

Source: [vitejs/vite `.github/workflows/publish.yml` lines 13-17](https://github.com/vitejs/vite/blob/d2e467d8b68525ea111d7c3f6bae24ebd7afe672/.github/workflows/publish.yml#L13-L17) at commit `d2e467d`.

### fastapi/fastapi - .github/workflows/publish.yml

```yaml
permissions: {}
# ...
  publish:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
```

Key line: `id-token: write`.

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

Source: [fastapi/fastapi `.github/workflows/publish.yml` lines 8-8,11-15](https://github.com/fastapi/fastapi/blob/7cb06f360dd44efac059848df1a9beee7643b018/.github/workflows/publish.yml#L8-L15) at commit `7cb06f3`, non-adjacent lines joined by `# ...`.

### better-auth/better-auth - .github/workflows/release.yml

```yaml
permissions: {}
# ...
    environment: Release
    permissions:
      contents: write
      pull-requests: write
      id-token: write
```

Key line: `id-token: write`.

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.

Source: [better-auth/better-auth `.github/workflows/release.yml` lines 28-28,44-48](https://github.com/better-auth/better-auth/blob/f23ce5012ea47fac1a69b1dad203dfdef3830fd0/.github/workflows/release.yml#L28-L48) at commit `f23ce50`, non-adjacent lines joined by `# ...`.

<a id="shipped-by-starsling"></a>

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

### Publishing tokens scoped to publish jobs

mastra-ai/mastra#16509: ci(security): scope id-token: write to publish jobs only
Removed OIDC token access from the nonpublishing job; the three publishing jobs retain it.

StarSling moved id-token: write out of workflow scope so only publish jobs can mint an OIDC token.

Diff: npm-publish.yml adds per-job permissions blocks and keeps id-token: write on the publish path only.

Source: [mastra-ai/mastra#16509](https://github.com/mastra-ai/mastra/pull/16509), merged 2026-05-12.
### Customer case study

[How Mastra made their slowest test suite 6x faster and cut queue time by 8x with StarSling](https://starsling.dev/customers/mastra)

Their slowest test suite runs in 5 minutes instead of 30, and under load the job queue fell from about fifteen minutes to under two minutes. Every fix scoped and shipped by the agents.


<a id="write-token-on-untrusted-trigger"></a>

## 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](https://www.stepsecurity.io/blog/elementary-data-compromised-on-pypi-and-ghcr-forged-release-pushed-via-github-actions-script-injection) 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](/skills/ci-secure) scans workflows for.

<a id="why-it-matters"></a>

## 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](https://tanstack.com/blog/npm-supply-chain-compromise-postmortem): 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.

<a id="when-to-use"></a>

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

<a id="verify"></a>

## Verify on your repo

Hand this prompt to your coding agent 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: write` appears in a top-level `permissions:` 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 no `id-token` declaration.
- Confirm hardened spellings elsewhere (`id-token: none`/`read`) are untouched, only the workflow-level `write` is the finding.

## Go further

- [Install the ci-score skill](https://starsling.dev/skills/ci-score) to grade your workflow configuration against all 11 CI Score checks and get a ranked fix for every gap. It grades configuration, not speed.

<a id="more-best-practices"></a>

## More best practices for GitHub Actions

- [Pin GitHub Actions to commit SHAs](https://starsling.dev/best-practices/github-actions/pin-action-shas)
- [GitHub Actions permissions: use least privilege](https://starsling.dev/best-practices/github-actions/limit-github-actions-permissions)
- [GitHub Actions secrets: prevent leaks and overexposure](https://starsling.dev/best-practices/github-actions/protect-github-actions-secrets)
- [All GitHub Actions best practices](https://starsling.dev/best-practices/github-actions)

<a id="speedup-guides"></a>

## 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](/skills/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](https://starsling.dev/github-actions-too-slow)
- [Fast GitHub Actions](https://starsling.dev/fast-github-actions)
- [GitHub Actions alternatives](https://starsling.dev/github-actions-alternatives)
- [GitHub Actions pricing](https://starsling.dev/github-actions-pricing)
- [Self-hosted GitHub Actions runners](https://starsling.dev/self-hosted-github-runners)
- [GitHub Actions CI/CD pipeline](https://starsling.dev/github-actions-ci-cd)
- [Docker CI on GitHub Actions](https://starsling.dev/ci/docker)
- [Install the free /ci-score skill to improve your GitHub Actions setup](https://starsling.dev/skills/ci-score)
- [Install the /ci-secure skill to close critical attack vectors in GitHub Actions](https://starsling.dev/skills/ci-secure)
- [Browse GitHub Actions agent skills](https://starsling.dev/skills)

<a id="faq"></a>

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

<a id="sources"></a>

## Sources

1. [GitHub - OIDC reference: scoping id-token to one job](https://docs.github.com/en/actions/reference/security/oidc)
2. [GitHub - OpenID Connect (OIDC) for Actions](https://docs.github.com/en/actions/concepts/security/openid-connect)
3. [GitHub - security hardening for GitHub Actions](https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions)
