Cloud Run Service-to-Service Auth: OIDC Tokens, IAM Conditions, and the Audience Trap
Cloud Run's built-in OIDC identity tokens simplify service-to-service auth, but audience validation, IAM condition limits, and local dev gaps require deliberate design. A worked example shows least-privilege bindings and the metadata server call pattern.
08 Oct 2025, 22:50 UTC

The problem: service-to-service calls without secret sprawl
You have two Cloud Run services. Service A needs to call Service B. You could provision API keys, manage mutual TLS certificates, or stuff bearer tokens into Secret Manager — but each approach adds operational overhead and rotates credentials on a schedule nobody remembers.
Cloud Run offers a built-in alternative: OIDC identity tokens minted by the metadata server, automatically validated by the receiving service when the right IAM binding exists. It works well, but the defaults hide a few sharp edges around audience validation, IAM condition expressiveness, and local development ergonomics.
How the built-in flow works
When Service A makes an outbound HTTP request to Service B's HTTPS URL, the Cloud Run runtime injects an Authorization: Bearer <token> header. The token is an OIDC identity token fetched from the metadata server at http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=<target-url>. The token's email claim matches Service A's service account; the aud claim defaults to the target service's URL.
Service B validates this token automatically — no application code required — if and only if the caller's service account holds the run.routes.invoke permission on the target service resource. That permission is granted via an IAM binding on Service B:
gcloud run services add-iam-policy-binding service-b \
--region=us-central1 \
--member=serviceAccount:[contact removed] \
--role=roles/run.invoker
Without this binding, Service B returns 403 with "Permission denied." The validation happens at the Google Front End (GFE) before the request reaches your container.
Least-privilege with IAM Conditions
The basic binding grants invocation on any path and HTTP method. For zero-trust segmentation, attach an IAM Condition to the binding. Supported Common Expression Language (CEL) functions let you restrict by path prefix, exact path, HTTP method, or the caller's service account email:
gcloud run services add-iam-policy-binding service-b \
--region=us-central1 \
--member=serviceAccount:[contact removed] \
--role=roles/run.invoker \
--condition='expression=request.path.startsWith("/internal/") && request.auth.claims.email == "[contact removed]",title=internal-only,description=Allow only /internal/* from caller'
Attempting GET /public/health now returns 403 even though the service account has run.invoker. Note: CEL in IAM Conditions does not support regex or wildcards — only startsWith, endsWith, and exact matches. Plan your URL hierarchy accordingly.
Worked example: Service A → Service B
Setup
- Service A:
[contact removed](default service account) - Service B: deployed at
https://service-b-xyz.run.app, ingress set toall(default) - IAM binding on Service B:
roles/run.invokergranted to caller with conditionrequest.path.startsWith("/internal/")
Service A code (Node.js)
async function fetchInternalData() {
const targetUrl = 'https://service-b-xyz.run.app/internal/data';
const metadataUrl = `http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=${encodeURIComponent(targetUrl)}`;
const tokenResp = await fetch(metadataUrl, { headers: { 'Metadata-Flavor': 'Google' } });
const identityToken = await tokenResp.text();
const resp = await fetch(targetUrl, {
headers: { 'Authorization': `Bearer ${identityToken}` }
});
return resp.json();
}
Key details
- The
audiencequery parameter must match the target URL exactly. Omitting it returns a token withaudset to the metadata server URL, which Service B will reject. - If Service A uses a user-managed service account via
--service-account, the metadata path changes to/computeMetadata/v1/instance/service-accounts/<email>/identity?audience=.... Construct the path dynamically from theGOOGLE_CLOUD_RUN_SERVICE_ACCOUNTenvironment variable. - First request after a cold start incurs ~100–300 ms latency while the metadata server mints the token. Warm instances cache the token until near its ~1-hour expiry.
Trade-offs and limitations
Token lifetime and clock skew
Tokens live ~1 hour (non-configurable). Google's infrastructure runs NTP, so skew is rare, but a misconfigured underlying host or a test VM with drifted clock can produce "token expired" or "token not yet valid" 401 errors. Implement retry-with-backoff on 401 and surface the error for alerting.
Audience coupling
The caller must know the target's exact URL at deploy time because the audience is baked into the token request. Changing Service B's URL (e.g., custom domain mapping, region migration) requires updating Service A's audience parameter and redeploying. If you place a Global External HTTP(S) Load Balancer in front of Service B and override the audience to the LB's hostname, Service B's built-in validation no longer matches — you must set --ingress=all and verify the audience in application code, losing the GFE-level protection.
Local development
The metadata server is unavailable locally. Three workarounds:
gcloud auth print-identity-token --audience=<target-url>for manualcurltesting.- Cloud Run Local Emulator (experimental) with
--allow-unauthenticatedon the target service. - A dev-only sidecar that mints self-signed OIDC tokens from a local keyset (more effort, closer to prod behavior).
Non-transitive identity
If Service A → Service B → Service C, Service A's identity does not propagate to Service C. Service B must either forward the original token (risking audience mismatch) or obtain a new token for Service C using its own service account. Plan your call chains accordingly.
Network-level isolation
VPC Service Controls perimeters do not automatically restrict Cloud Run service-to-service calls; traffic traverses Google's front-end infrastructure. If you need network-level guarantees, use Serverless VPC Access connectors with Private Google Access and egress controls.
Actionable checklist
- Grant
roles/run.invokeron the target service to the caller's service account. - Add an IAM Condition restricting path prefix (
request.path.startsWith("/internal/")) and caller email. - In the caller, fetch the token from the metadata server with
audience=<exact-target-url>. - Handle 401 with exponential backoff; log audience mismatches separately from expiry.
- For local dev, use
gcloud auth print-identity-tokenagainst a deployed target with--allow-unauthenticatedtemporarily enabled. - Monitor
run.googleapis.com/requestswithresponse_code_class=4xxto catch auth failures early.
The built-in OIDC flow eliminates secret management for the common case. Just remember: the audience is a contract, IAM Conditions are your segmentation layer, and the metadata server path depends on which service account the caller runs as.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.