# GitHub Actions id-token write permission not granted to the job

> A GitHub Actions id-token write permission not granted issue is about placement. The message is identical wherever the block went, so read the file.

Source: https://latchkey.dev/learn/github-actions/gha-oidc-id-token-permission  
Updated: 2026-09-20

A GitHub Actions id-token write permission not granted problem is almost never a missing line; it is a line in a place that does not reach the job that needs it. The failure text is the same in every case, so the workflow file rather than the log is what distinguishes them.

## What this error means

An OIDC login fails and the workflow visibly contains `id-token: write`. Adding it again somewhere else changes nothing, and the job summary lists a permission set that does not include it. The message is identical to the one you get from having no permission at all, which is why the file gets re-read several times before anyone checks what the job was actually granted.

```Actions log, quoted from claudioorjunior/integra-escala#36
Failed to get OIDC token: Error message: Unable to get ACTIONS_ID_TOKEN_REQUEST_URL env variable
Error: Unexpected error
```

## Common causes

### The job has its own permissions block that omits id-token

The common case, and it usually arrives through a well-intentioned change. Someone tightens a job by giving it `contents: read`, which silently discards everything the workflow level was providing, including the OIDC permission that another part of the job depends on.

### The permission is inside a reusable workflow rather than at its caller

The called file is correct in isolation and cannot help itself. Every calling job has to declare the permission, so one shared workflow generates the same report from several repositories, none of which contains the fix.

### The login happens in a different job from the one holding the permission

Splitting build and deploy into two jobs moves the OIDC login without moving the block, especially when the permission was written at a job that no longer performs the login. Nothing warns, because both jobs are individually valid.

### An organization or enterprise policy withholds the scope

Rare but real, and the only case where the file is genuinely correct and unfixable locally. The job requests the permission and is granted less, which the run's permissions listing shows and the workflow file cannot.

## How to fix it

### Compare what was requested with what was granted

1. Open the failing job and read the permissions it was given, rather than the ones the file asks for.
2. If `id-token` is absent there but present in the file, find which block actually applied to this job.
3. If the job has its own `permissions` block, that block is the complete list and needs every scope the job uses.

### Put the whole list on the job that logs in

When a job declares any permissions, declare all of them. This is the placement that does not depend on anything above it.

```.github/workflows/deploy.yml (illustrative)
jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v5
      - uses: aws-actions/configure-aws-credentials@v5
        with:
          role-to-assume: arn:aws:iam::123456789012:role/deploy
          aws-region: eu-west-1
```

### Declare it at the call site for a reusable workflow

The calling job is the only place that can grant it. Write it there even when the called workflow also declares it, since the called file can only narrow what it was given.

```.github/workflows/release.yml (illustrative)
jobs:
  call-deploy:
    permissions:
      id-token: write
      contents: read
    uses: my-org/shared/.github/workflows/deploy.yml@v3
```

### Keep the login in the job that holds the permission

When jobs are split, move the block with the step that mints the token rather than leaving it where it was. Credentials obtained by OIDC do not cross between jobs anyway, so each job that authenticates needs its own permission.

## How to prevent it

- Treat a job-level `permissions` block as a complete list, and review it as one whenever a job gains a step.
- Document at the top of a reusable workflow which permissions the caller must grant.
- Put the permission on the job that performs the login, not on the workflow, so splitting jobs makes the loss obvious.
- Read the granted permissions in the run summary before editing the file a second time.

## The message is the same as its sibling, deliberately

This page and the one on the missing request URL describe the same failure text, because there is only one. The toolkit reads an environment variable, finds nothing, and throws; it cannot tell you why the variable was absent. That sibling page covers the mechanism, the wrapper prefixes and the two variables. This one is about the thing that is actually different between repositories, which is where the permission was written.

If you have already established that the job simply never asked for the permission, the repair is one line and belongs on the sibling. Come here when the line demonstrably exists and the job still does not have it.

## Where the block reaches and where it does not

Permissions are computed per job, and a job-level block is a replacement rather than an addition. That single rule explains most of these.

| How the file is written | What the job ends up holding |
| --- | --- |
| `id-token: write` at the workflow level only, job declares nothing | The workflow block, so the job holds it |
| `id-token: write` at the workflow level, job declares `contents: read` | Only `contents: read`, because the job block replaced the workflow one |
| `id-token: write` on the job itself | The job block, which is the reliable placement |
| `id-token: write` inside a called reusable workflow | At most what the caller's job held, so usually nothing |

> The second row is the one that produces the most confusion, because the workflow plainly contains the permission and a job plainly does not have it. Both statements are true at once. When a job declares any `permissions` block, that block is the complete list for that job, and anything the workflow level offered is discarded rather than merged.

## The caller decides for a reusable workflow

A called workflow cannot grant itself more than the caller had, so writing the permission inside the reusable file achieves nothing when the calling job did not hold it. GitHub is explicit for workflows outside your own organization: set the `permissions` setting for `id-token` to write "explicitly at the caller workflow or job level", which "ensures the OIDC token is only available to intended caller workflows".

The practical consequence is that the file you have to edit is not the file that failed. A team that owns a shared deployment workflow will keep receiving reports about it that can only be fixed in the repositories calling it, which is worth documenting next to the workflow.

## Why there is no recorded run on this page

What distinguishes this page from its sibling is not observable in a log at all: it is the shape of a workflow file, and every log for every placement is byte for byte the same. Recording a run would produce the same line the sibling already quotes, with nothing in it to indicate which of the four placements above was in effect. The evidence that does discriminate is the run's own permissions listing in your repository, and GitHub's published rule for callers, and neither is something a run of ours could stand in for.

## FAQ

### How is this different from the missing request URL page?

The failure text is the same, because there is only one message. That page explains the mechanism: which environment variable is read, in what order, and what the toolkit wraps around the throw. This page is for when the permission is visibly in the file and the job still does not hold it, which is a question about placement rather than about the message.

### Does a job-level permissions block merge with the workflow-level one?

No, it replaces it. A job that declares any permissions holds exactly what it declared, so adding `contents: read` to a job silently removes an `id-token: write` that the workflow level was supplying. This is the single most common reason the line is present and the job does not have it.

### Why does declaring it in the reusable workflow not work?

Because a called workflow cannot hold more than its caller. GitHub instructs you to set `id-token` to write explicitly at the caller workflow or job level, so that the OIDC token is only available to intended callers. The file that must change is the one doing the calling.

### The file looks right and the job still lacks the permission. What now?

Read the run's permissions listing and compare it with the request. If the job asked for the scope and was granted less, no edit to the workflow will change it and the next question is an organization or enterprise policy, which someone with those settings has to answer.

## References

- [GitHub Actions: OIDC reference, setting permissions and the caller rule](https://docs.github.com/en/actions/reference/security/oidc)
- [GitHub Actions: workflow syntax, how permissions are calculated for a job](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#permissions)
- [claudioorjunior/integra-escala#36: the same message from a real run](https://github.com/claudioorjunior/integra-escala/issues/36)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
