---
title: "Scope id-token to one job: safer OIDC in GitHub Actions"
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

Best practice: Security. Last updated: 2026-07-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.

## 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@v4
      - 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@<sha>
```

## 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@<sha>
```

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

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

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.

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

## More best practices for GitHub Actions

- [Pin GitHub Actions to commit SHAs](https://starsling.dev/best-practices/github-actions/pin-action-shas)
- [All CI best practices](https://starsling.dev/best-practices/github-actions)

## Sources

1. [GitHub - OpenID Connect (OIDC) for Actions](https://docs.github.com/en/actions/concepts/security/openid-connect)
2. [GitHub - security hardening for GitHub Actions](https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions)
