OIDC GCP Unable to acquire impersonated credentials in CI
The OIDC GCP Unable to acquire impersonated credentials error is raised by google-auth-library, not by the GitHub Action that logged you in, and it is a wrapper around a Google response that carries the real reason. The step that fails is almost never the step that authenticated.

What this error means
The google-github-actions/auth step is green. A later step that runs gcloud, Terraform or a Python client goes red with a RefreshError whose first element opens with the same sentence every time, sometimes with one further clause appended, followed by a second element containing a JSON body. Because those opening words never change, it looks like a single fault with a single fix, and because the auth step passed, the workflow file looks correct. Everything that distinguishes one cause from another is inside the JSON.
google.auth.exceptions.RefreshError: ('Unable to acquire impersonated credentials: No access token or invalid expiration in response.', '{\n "error": {\n "code": 403,\n "message": "Permission \'iam.serviceAccounts.getAccessToken\' denied on resource (or it may not exist).",\n "status": "PERMISSION_DENIED",\n "details": [\n {\n "@type": "type.googleapis.com/google.rpc.ErrorInfo",\n "reason": "IAM_PERMISSION_DENIED",\n "domain": "iam.googleapis.com",\n "metadata": {\n "permission": "iam.serviceAccounts.getAccessToken"\n }\n }\n ]\n }\n}\n')The action is usually not the thing that failed
Signing in to Google Cloud from Actions has two legs. The first exchanges GitHub's OIDC token for a federated token at Google's security token service. The second uses that federated token to impersonate a service account and obtain an access token. google-github-actions/auth performs the first leg and then, in its default mode, writes a credential file describing how to do the second. It does not necessarily perform the second itself.
That is why the step is green. Whatever loads the credential file afterwards does the impersonation, and if that consumer is gcloud or a Python client library, the code doing the work is google-auth-library. The sentence on this page is defined there, in impersonated_credentials.py, as a module constant named _REFRESH_ERROR. It is not in the action's source at all: the action is TypeScript, and searching its client files for the phrase returns nothing while other strings from the same files are found immediately.
Common causes
The service account has no Service Account Token Creator binding for the principal
The common one, and the one the library documents first. The binding has to name the workload identity principal, not the pool and not the provider, and the principalSet:// string has to match the attribute mapping exactly. A binding that looks right but names the pool rather than the subject fails in this way.
The IAM Service Account Credentials API is not enabled
Also documented as a common reason. The project can have every binding correct and still refuse, because the endpoint being called is not switched on. It is easy to miss because the error mentions a permission rather than an API, and because enabling it is done in a different console page from the bindings.
The service account address is malformed
The 400 case. An unexpanded template or a variable that resolved to an empty string produces an address Google will not parse, and the message says so explicitly in the attached body while the wrapper sentence stays the same. In our experience this is the most common cause in workflows that build the address from inputs.
The federated identity is not who you think it is
Worth ruling out when the bindings are provably present. The attribute mapping decides what the principal string resolves to, so a mapping that emits a different subject silently produces a principal with no bindings anywhere. The refusal is then correct and the binding you are reading is for somebody else.
How to fix it
Read the JSON before changing any IAM
- Take the second element of the exception and find the
codeandstatusfields. - A 400 means the address is wrong and no IAM change will help.
- A 403 naming
iam.serviceAccounts.getAccessTokenmeans the role binding; a 403 naming the credentials API means the API. - On google-auth 2.9.1 and later, a sentence continuing with "No access token or invalid expiration in response." moves the problem to the response rather than the permission; on older versions that clause can accompany a refusal, so the
codestill decides.
Bind the exact principal the mapping produces
Grant Service Account Token Creator on the target service account to the principal your attribute mapping actually emits. Confirm the string rather than assuming it: the value that matters is the one in the provider's attribute mapping, and the binding must match it character for character.
Skip the second leg when you do not need it
Impersonation is optional. If the workloads you run can use the federated identity directly, request direct workload identity federation from the auth step instead of naming a service account, and the impersonation call is never made. Nothing can then fail in the way this page describes, and one IAM object disappears from your setup.
Enable the credentials API and confirm in the same place
Enable the IAM Service Account Credentials API on the project that owns the service account, which is not always the project that owns the workload identity pool. When the two differ, check which project the request reached before concluding the API is off.
Read the second element, not the first
The library raises RefreshError(_REFRESH_ERROR, response_body), so the exception always carries two parts, and the second is the one where Google speaks. Its status and reason live there, and they are what separate these causes.
| Code in the attached body | Google's status | What is actually missing |
|---|---|---|
| 403 | PERMISSION_DENIED on iam.serviceAccounts.getAccessToken | The principal has no Service Account Token Creator role on the target service account |
| 403 | PERMISSION_DENIED naming iamcredentials.googleapis.com | The IAM Service Account Credentials API is not enabled on the project |
| 400 | INVALID_ARGUMENT about the form of the account ID | The service account address is malformed, often an unexpanded template |
| 404 | Not found on the service account resource | The service account does not exist in the project the request reached |
Two raise sites, one sentence
There are two places the constant is used and they mean different things. The first fires when the call to the credentials endpoint returns any status other than 200, and it attaches the response body unchanged. The second fires when the body parsed but held no accessToken or no readable expireTime, and it formats the same constant with "No access token or invalid expiration in response." appended.
The suffix is worth looking for, and it matters which version of the library you are reading it on. From google-auth 2.9.1 the non-200 guard raises, so the suffix does mean Google answered 200 and the answer was unusable, which points at the shape of the response rather than at a permission. Before 2.9.1 that guard built the exception and never threw it, so a non-200 fell through into the same try: the error JSON parsed, the absent accessToken raised KeyError, and the second site produced the suffixed message carrying a refusal body. The repair was one word, raise, in commit d1f17b0, and v2.9.1 is the release that shipped it.
The traceback at the top of this page carries the suffix and a 403 PERMISSION_DENIED on iam.serviceAccounts.getAccessToken at the same time, which is the combination the older path produces. So the reading that is safe on every version is the one this page opens with: if the attached body has a code and a status, those are the answer, and the suffix only tells you which raise site was reached. Check the pinned google-auth version before letting that clause steer you away from a permission.
Why there is no recorded run on this page
Every fact this error reports is about a Google Cloud project's IAM: which role is bound to which principal, which API is enabled, which service account exists. A run of ours would be a statement about our project's bindings, and the log it produced would carry our service account address and our project number, neither of which you can compare against yours. What is reproducible here is the code path, and we read that instead, in the library that raises the sentence and in the action that does not. The traceback above is quoted from a public issue.
How to prevent it
- Keep the binding and the attribute mapping in the same piece of configuration, so a change to one cannot silently orphan the other.
- Prefer direct workload identity federation where the consumer supports it, and impersonate only when something genuinely requires a service account identity.
- Build the service account address from a single variable, so a 400 is impossible rather than merely unlikely.
- Log the resolved principal once during setup, since it is the value every binding has to match.
Frequently asked questions
Why does the auth step pass and a later step fail?
gcloud or a client library loads it, and that is where the refusal occurs.Is this message produced by google-github-actions/auth?
_REFRESH_ERROR in impersonated_credentials.py. The action is written in TypeScript and the phrase appears nowhere in its client sources, while other strings from those same files are found by the same search. Blaming the action version rarely changes anything.What does "or it may not exist" mean in the 403?
How is this different from an audience error?
Related guides
References
- google-auth-library-python: impersonated_credentials.py, where the sentence is defined and raised
- google-github-actions/auth: the client sources that perform the federated token exchange
- google-auth-library-python v2.9.1: the release that added the missing raise to the non-200 guard
- mscribellito/terraform-google-workload-identity-federation#4: the traceback with Google's body attached
- Google Cloud: workload identity federation with GitHub Actions
- GitHub Actions documentation