# OIDC GCP Unable to acquire impersonated credentials in CI

> OIDC GCP Unable to acquire impersonated credentials is raised by a Google library after the auth step passed. The attached body is the real answer.

Source: https://latchkey.dev/learn/github-actions/oidc-gcp-unable-to-acquire-impersonated-credentials-in-ci  
Updated: 2026-09-20

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.

```Traceback quoted from mscribellito/terraform-google-workload-identity-federation#4
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')
```

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

1. Take the second element of the exception and find the `code` and `status` fields.
2. A 400 means the address is wrong and no IAM change will help.
3. A 403 naming `iam.serviceAccounts.getAccessToken` means the role binding; a 403 naming the credentials API means the API.
4. 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 `code` still 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.

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

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

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

> The library's own docstring names the first two as the expected ones: it documents the error as "Raised if the impersonated credentials are not available" and adds that "Common reasons are `iamcredentials.googleapis.com` is not enabled or the `Service Account Token Creator` is not assigned". A 403 mentioning the resource "or it may not exist" is deliberately ambiguous, because Google does not confirm the existence of a resource to a caller that cannot see it.

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

## FAQ

### Why does the auth step pass and a later step fail?

Because they do different legs. The action exchanges the OIDC token for a federated token and writes a credential file, which succeeds as long as the pool and provider accept your repository. The impersonation described in that file happens later, when `gcloud` or a client library loads it, and that is where the refusal occurs.

### Is this message produced by google-github-actions/auth?

No. The string is defined in google-auth-library-python as the constant `_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?

It means Google is declining to tell you whether the service account exists. The same response is returned when the resource is absent and when it is present but invisible to the caller, because confirming existence would leak information. Treat it as one of two possibilities rather than as confirmation that the account is there.

### How is this different from an audience error?

An audience failure happens on the first leg, inside the auth step, and the action reports it with its own prefix about generating a federated token. This message happens on the second leg, after that step has already succeeded. If your auth step is green, the audience was accepted and the problem is downstream of it.

## References

- [google-auth-library-python: impersonated_credentials.py, where the sentence is defined and raised](https://github.com/googleapis/google-auth-library-python/blob/main/google/auth/impersonated_credentials.py)
- [google-github-actions/auth: the client sources that perform the federated token exchange](https://github.com/google-github-actions/auth/tree/main/src/client)
- [google-auth-library-python v2.9.1: the release that added the missing raise to the non-200 guard](https://github.com/googleapis/google-auth-library-python/releases/tag/v2.9.1)
- [mscribellito/terraform-google-workload-identity-federation#4: the traceback with Google's body attached](https://github.com/mscribellito/terraform-google-workload-identity-federation/issues/4)
- [Google Cloud: workload identity federation with GitHub Actions](https://cloud.google.com/iam/docs/workload-identity-federation-with-deployment-pipelines)

---

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
