All work
Case study 04Cross-cloud SSOEnterprise project · 2026

The proxy we never needed

Users signed in with Microsoft Entra; the private backend only trusted Google. Single sign-on took one service fewer, not one more.

ReactMSALMicrosoft Entra IDNode.js + ExpressCloud RunIdentity-Aware ProxyInternal load balancerCloud IAMPython API
Identity
2 providersMicrosoft Entra for people · Google for services
Planned
3 servicesfrontend, token proxy, backend
Blocked at
the edgerequests never reached the proxy's code
Shipped
2 servicesthe exchange moved into the frontend container
Backend
still privateGoogle ID token · one run.invoker binding

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.
PLANNED · THREE SERVICESFRONTEND1 · sign in with entra2 · send entra tokenPROXY3 · validate entra4 · mint google id5 · forwardBACKEND6 · verify, then serve401 at the edge · steps 3–6 never ran
Fig. 1 · The plan, six stepsStopped at step 2

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 service built to hand out Google tokens needed a Google token to be reached.
REACH THE PROXYGET A GOOGLE TOKENno way inneedsneeds
Fig. 2 · The loopThe minting service needed what it mints

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 Authorization or X-Serverless-Authorization.
  • The right audience: the service's own run.app URL, or a custom audience configured on it. Custom domains don't count.
  • A caller it trusts: an identity holding roles/run.invoker on 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.

Option A · open the door

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.

Option B · change the language

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.

Option C · shipped

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.invoker on the backend. That one binding is the whole trust relationship.
BROWSERmsal sign-inFRONTEND · CLOUD RUNREACT APPEXPRESS LAYER1 · validate entra token2 · mint google id tokenaud = backend url3 · forward, return replyentra200proxygoogle idresponseBACKEND · CLOUD RUNEDGE CHECKgoogle-signed · run.invokerPYTHON APIonly sees checked calls
Fig. 3 · What shippedTwo services · the backend stays private
Net resultthree services planned, two shipped: the token proxy became a route handler

Why it's better, not just shorter

Surface

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.

Trust

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.

Operations

One fewer service

Nothing extra to deploy, scale, monitor or patch, and one fewer network hop on every call.

The same edge check that stopped the proxy now guards the backend.

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 Authorization is already taken by your own token, send the Google one in X-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.
What it taught me

Four things I kept.

01

A rejection is a design review.

The edge refused exactly what it was built to refuse. The design had to change, not the door.

02

Swap tokens where the identity lives.

The frontend container had a Google identity from day one. The proxy would have needed one just to be reached.

03

Every service is a door.

A new service isn't only code to deploy; it's a new boundary to authenticate. Count the doors before adding one.

04

Start from the documented pattern.

Google's service-to-service guide described the whole fix. The custom proxy was the unusual path, and the one that broke.

Got a hard problem?

Let's talk about yours.