Skip to content
Latchkey LogoLatchkey home

GitHub Actions OIDC failed to get ID token, and the wrapping around it

GitHub Actions OIDC failed to get ID token is one of three distinct failures in @actions/core, and it is the only one that means a request actually went to the token endpoint. Because every one of the three is rewrapped by the same catch on the way out, they arrive in the log looking almost identical, and telling them apart is most of the work.

Three failure branches in the OIDC client and the single catch that rewraps them all
All three branches and the rewrap are read from OidcClient in packages/core/src/oidc-utils.ts. The retry policy is from packages/http-client.

What this error means

A step that exchanges an OIDC token for cloud credentials fails before any cloud API is called. What you see is a chain of prefixes: the calling action adds one, @actions/core adds Error message: to whatever it caught, and then the original sentence follows. That original is the part to isolate. Unable to get ACTIONS_ID_TOKEN_REQUEST_URL env variable means no request was attempted, because the client checks for its inputs first. Failed to get ID Token. means the request was attempted and the response was the problem, and it carries a status code on its own indented line. Response json body do not have ID Token field means the response arrived and parsed and had no token in it.

Reconstructed from the template literals in oidc-utils.ts; the newlines, trailing spaces and indentation are in the source string
Error: Error message: Failed to get ID Token. 
 
        Error Code : 403
 
        Error Message: Unauthorized

Three throws, one catch, and why they look the same

OidcClient in @actions/core does four things in order. It reads ACTIONS_ID_TOKEN_REQUEST_URL, throws if it is missing, reads ACTIONS_ID_TOKEN_REQUEST_TOKEN when it builds the HTTP client and throws if that is missing, makes the request and throws on a rejected response, then reads the token out of the parsed body and throws if it is not there.

Then getIDToken wraps the lot in a try and, in its catch, throws a new error whose message is Error message: plus the caught message. That single rewrap is why a missing variable and a rejected request read so similarly in a log: they arrive with the same outer prefix and differ only in the sentence after it.

Sentence after the rewrapWhat it provesWhere to look
Unable to get ACTIONS_ID_TOKEN_REQUEST_URL env variableThe runner was never given the variableThe job's permissions block
Unable to get ACTIONS_ID_TOKEN_REQUEST_TOKEN env variableThe same, for the paired credentialThe job's permissions block
Failed to get ID Token. with a code and a messageA request was made and the response was rejectedThe status code in the message
Response json body do not have ID Token fieldA successful response carried no tokenThe audience, and the endpoint itself

Common causes

The response was rejected and the status code says so

The direct reading of this branch. The code is on its own line in the message and is the whole diagnosis: a 401 or 403 means the credential the client sent was refused, a 400 means the request was malformed or the audience was not acceptable, a 404 means the URL the runner supplied did not resolve. None of these are retried.

The audience does not match what the endpoint will issue

The audience is appended to the request URL by the client and comes from the action you called. It is the only part of the request that is yours, so when a rejection is not about credentials it is usually about this. In our experience this appears right after someone copies a login step between clouds and keeps the audience from the old one.

A gateway failure outlasted eleven attempts

Real, and narrower than it is usually assumed to be. The client retries 502, 503 and 504 up to ten times with exponential backoff, so a message carrying one of those codes describes a failure that persisted across all of them rather than a momentary blip.

You are actually in one of the other two branches

The rewrap makes all four failures look alike, and the two about missing environment variables are far more common than this one. If the sentence after Error message: begins with Unable to get, no request was made and the permissions block is where the answer is.

How to fix it

Isolate the sentence after the rewrap

  1. Find Error message: in the log and read what comes immediately after it.
  2. Unable to get ... means no request was made; go to the job's permissions block and stop here.
  3. Failed to get ID Token. means a request was made; find the line beginning Error Code : and take the number.
  4. Response json body do not have ID Token field means the response was fine and empty of a token.

Read the status code before you re-run

A 401, 403, 400 or 404 will reproduce exactly on a re-run, because the client does not retry them. Re-running is only worth doing for a gateway code, and even then the client has already tried ten times, so an immediate second failure is expected rather than surprising.

Check the audience the calling action is sending

Each cloud login action exposes its own audience input and its own default. Set it explicitly to whatever the provider on the other side is configured to accept, rather than relying on a default that changed when you changed clouds.

.github/workflows/deploy.yml (illustrative)
      - uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
          audience: api://AzureADTokenExchange

Confirm the job was equipped to make the request at all

Even when the failure is an HTTP one, it is worth confirming the permission is where you think it is, because a job-level permissions block replaces the workflow-level one rather than merging with it. Granting it on the job that runs the login step removes the ambiguity.

.github/workflows/deploy.yml (illustrative)
  deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@v5

Where the runner gets the variables at all

This bounds what the failure can mean. In the runner's script handler, the two variables are set on the step environment only when the job's system connection carries a value named GenerateIdTokenUrl; if that value is absent or empty, neither variable is written and nothing on the runner can mint a token. The service that decides whether to include it is not published, so this page makes no claim about how it decides.

What the runner code does establish is that the runner is a courier rather than a gatekeeper: it copies what it was sent. So a missing-variable failure is a statement about the job as it was dispatched, and a Failed to get ID Token. failure is a statement about a request the runner was equipped to make.

The message is a template literal, spaces and all

The text of this failure is built from a template literal split across three source lines, and the whitespace is not tidied. The result is a message that genuinely contains a newline after the full stop, a trailing space, and eight spaces of source indentation before Error Code : and Error Message:. The space before the colon in Error Code : is in the source too.

That shape is useful. Searching a log for Error Code : with its odd spacing finds this branch and nothing else, and it rules out the variable branches immediately, because they are single sentences with no code in them. It is also why the failure looks mangled in a log viewer that collapses whitespace; nothing is truncated.

the first line of the message, trailing space included
Failed to get ID Token. 

The request has already been retried, which changes what a re-run means

The client is created with retries enabled and a maximum of ten, and the retry policy lives in @actions/http-client. It retries only idempotent verbs, and the token request is a GET, so it qualifies. It retries only three status codes: 502, 503 and 504. Between attempts it backs off exponentially, with the exponent capped.

So by the time you read this message, a gateway-class failure has already been attempted eleven times. That makes the usual advice, to re-run the job because the endpoint is sometimes flaky, much weaker than it sounds: a transient 5xx of exactly the kind people mean is the one case the client already handled. A 401, 403, 400 or 404 is returned on the first attempt and never retried, and those are the codes this branch usually carries.

Status in the messageRetried by the client?What it usually means
502, 503, 504Yes, up to ten times with backoffA gateway failure that outlasted every attempt
401 or 403NoThe request credential was rejected
400NoThe request itself was refused, often the audience
404NoThe endpoint in the variable did not resolve

The audience is the one part of the request you control

getIDToken takes an optional audience and, when given one, appends it to the request URL as an encoded query parameter. Everything else about the request comes from the two environment variables, which the runner wrote. So when the failure is a rejection rather than a network problem, the audience is the only input worth examining, and it is set by whichever action you are calling rather than by this client. A mismatch is a rejection here, before any cloud call, which separates it from the far more common failure where the token is minted and the cloud then declines to trust it.

Why there is no recorded run on this page

Producing this message on demand means getting the Actions token endpoint to return a non-2xx response, and the only honest ways to do that are to send it a credential it will refuse or to wait for an outage. The first would record a status code we manufactured and present it as an observation; the second is not something to schedule. The endpoint is closed, so we could not even say why it answered as it did. The branches, the wrapping, the retry policy and the spacing are all read from the published client, which is where they are checkable by anyone.

How to prevent it

  • Set id-token: write on the job that needs it rather than on the workflow, so no later job-level block can drop it.
  • Pin the audience explicitly on every cloud login step instead of inheriting an action default.
  • Record the status code in incident notes, because the four common ones point in four different directions.
  • Do not build automatic re-runs around this failure; the client has already retried the only codes worth retrying.

Frequently asked questions

What is the difference between "Failed to get ID Token" and "Unable to get ACTIONS_ID_TOKEN_REQUEST_URL"?
The second means the client never made a request, because it checks for its environment variables first and throws when one is missing. The first means a request was made and the response was rejected, and it carries a status code. Both arrive behind the same Error message: prefix, which is why they are so easily confused.
Why does the message contain blank lines and strange indentation?
Because it is built from a template literal split across three source lines in oidc-utils.ts, and the source indentation and trailing spaces end up inside the string. The space before the colon in Error Code : is in the source too. Nothing is truncated or corrupted.
Should I retry when the OIDC token request fails?
Usually not. The client is created with retries enabled and a maximum of ten, and it retries 502, 503 and 504 with exponential backoff. Those are the codes a retry would help with, and they have already been tried. Every other status is returned on the first attempt and will reproduce.
What does "Response json body do not have ID Token field" mean?
That the request succeeded, the body parsed, and there was no value field in it for the client to return. It is a separate throw from the HTTP branch, so it does not carry a status code, and it points at the request rather than at the workflow's permissions.

Related guides

References

Re-running a deploy costs what running it cost. On Latchkey that is $0.0025/min at 2 vCPU against $0.006. Start free → 30-day trial · No credit card