The setup
An enterprise AI agent application, split the usual way: a React frontend and a Python API backend, each its own Cloud Run service on Google Cloud. The frontend sits behind an internal load balancer and Identity-Aware Proxy. The people who use it sign in with Microsoft Entra ID, the organisation's directory.
The backend accepts only authenticated requests. That's where the two clouds meet: the user's proof of identity is a Microsoft token, and the check in front of the backend only reads Google ones.
The plan: a proxy in the middle
The first design added a third service, a token proxy, whose only job was to translate one identity into the other:
- The user signs in through the frontend with Entra.
- The frontend sends the Entra token to the proxy.
- The proxy validates the Entra token.
- The proxy mints a Google ID token with the backend as its audience.
- The proxy forwards the request to the backend.
- The backend accepts the Google token and serves the call.
Each hop was sound on paper. Nothing got past the second one.
Stopped at the door
Requests from the frontend never reached the proxy's code. The Cloud Run and IAP logs classified them as unauthenticated and rejected them before the application could run. So the proxy never validated a token, never minted one, and the backend never saw a request. The whole chain was blocked at its first new service.
The door was right
The rejection wasn't a misconfiguration. A Cloud Run service that requires authentication checks every request before the container sees it, and it looks for three things:
- A Google-signed ID token as a bearer token, in
AuthorizationorX-Serverless-Authorization. - The right audience: the service's own
run.appURL, or a custom audience configured on it. Custom domains don't count. - A caller it trusts: an identity holding
roles/run.invokeron that service.
IAP is just as strict. For programmatic calls it accepts Google identities only, user or service account; per Google's docs, even identities federated through Workforce Identity Federation aren't supported there. An Entra token, however valid, is none of these. Making the door read it meant either opening the door or teaching it a new language.
Three ways out
We wrote the questions down. Each was a different way out.
Unprotect the proxy
Allow unauthenticated calls and validate Entra in code. It works, but it leaves the one service that mints backend credentials reachable without a Google check. Any bug in its validation is an open door.
Federate the identity
Configure Google to trust Entra users directly. Heavy identity work for a single call path, and IAP's programmatic access doesn't support federated identities anyway.
Move the exchange
Do the token swap somewhere that already has a Google identity and already receives the user's request: the frontend's own container.
A layer, not a service
The frontend container already runs a server to deliver the React app, and like every Cloud Run service it already runs as a service account: a Google identity, available to its code through the metadata server. So the exchange moved there, into a small Express layer beside the frontend server, following Google's documented service-to-service pattern:
- Validate the Entra token the browser sends with each call, before anything else runs.
- Mint a Google ID token from the metadata server, with the backend's URL as its audience. No key file: the token belongs to the frontend's service account.
- Forward the request with
Authorization: Bearer <ID token>and pass the backend's response back to the browser. - Grant the frontend's service account
roles/run.invokeron the backend. That one binding is the whole trust relationship.
Why it's better, not just shorter
No new door
The proxy would have been one more endpoint to protect. The Express layer sits behind the frontend's existing load balancer and IAP.
One IAM binding
The backend trusts exactly one caller, the frontend's service account, and Cloud Run enforces that before the API's code runs.
One fewer service
Nothing extra to deploy, scale, monitor or patch, and one fewer network hop on every call.
Notes for the next team
- The backend doesn't have to verify the Google token itself. With authentication required, Cloud Run checks it at the edge. Re-checking in code is optional depth, not the gate.
- Mind the audience. It must be the receiving service's URL or a configured custom audience; a custom domain won't match.
- If
Authorizationis already taken by your own token, send the Google one inX-Serverless-Authorization. Cloud Run checks that header instead and strips the signature before your code sees it. - ID tokens expire after about an hour. Google's auth libraries cache and refresh them; a hand-rolled metadata call should too.
- The metadata server only exists on Google Cloud. Locally, mint the token by impersonating a service account.