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.

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.
Error: Error message: Failed to get ID Token.
Error Code : 403
Error Message: UnauthorizedThree 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 rewrap | What it proves | Where to look |
|---|---|---|
Unable to get ACTIONS_ID_TOKEN_REQUEST_URL env variable | The runner was never given the variable | The job's permissions block |
Unable to get ACTIONS_ID_TOKEN_REQUEST_TOKEN env variable | The same, for the paired credential | The job's permissions block |
Failed to get ID Token. with a code and a message | A request was made and the response was rejected | The status code in the message |
Response json body do not have ID Token field | A successful response carried no token | The 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
- Find
Error message:in the log and read what comes immediately after it. Unable to get ...means no request was made; go to the job'spermissionsblock and stop here.Failed to get ID Token.means a request was made; find the line beginningError Code :and take the number.Response json body do not have ID Token fieldmeans 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.
- 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://AzureADTokenExchangeConfirm 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.
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v5Where 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.
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 message | Retried by the client? | What it usually means |
|---|---|---|
| 502, 503, 504 | Yes, up to ten times with backoff | A gateway failure that outlasted every attempt |
| 401 or 403 | No | The request credential was rejected |
| 400 | No | The request itself was refused, often the audience |
| 404 | No | The 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: writeon 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"?
Error message: prefix, which is why they are so easily confused.Why does the message contain blank lines and strange indentation?
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?
What does "Response json body do not have ID Token field" mean?
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
- actions/toolkit: packages/core/src/oidc-utils.ts, all four throws and the rewrap
- actions/toolkit: packages/http-client, the retry codes, verbs and backoff
- actions/runner: ScriptHandler.cs, where the two OIDC environment variables are written
- GitHub Docs: OpenID Connect concepts, including the audience and the permission it needs
- GitHub Actions documentation