ZuploZuplo
LoginStart for Free
  • Documentation
  • API Reference
Introduction
Getting Started
    Develop on the web portal
      1 - Setup Your Gateway2 - Rate Limiting3 - API Key Auth4 - Deploy5 - Dynamic Rate LimitingDynamic MCP Server - Quickstart
    Develop locally with the CLI
      1 - Setup Your Gateway2 - Rate Limiting3 - API Key Auth4 - Deploy5 - Dynamic Rate LimitingDynamic MCP Server - Quickstart
Concepts
Development
Policies
    Policy Catalog
    Authentication
    Authorization
    MCP Authorization
    Security & Validation
    Metrics, Billing & Quotas
    Testing
    Request Modification
    Response Modification
    Upstream Authentication
    GraphQL
    Other
    Guides
      Multiple Auth PoliciesPer-user rate limitsComposite Policy PatternsClient mTLSCloud Workload Identity
Handlers
API Keys
Rate Limiting
MCP Server
MCP Gateway
AI Gateway
Developer Portal
Monetization
GraphQL
Deploying & Source Control
Analytics
Observability
Networking & Infrastructure
Account Management
Programming API
Build with AI
Zuplo CLI
Migration Guides
Platform LimitsVersion Support PolicySecuritySupportTrust & ComplianceChangelog
powered by Zudoku
Guides

Authenticate a cloud workload identity

A workload running in a cloud already has an identity: a Google service account, a Kubernetes service account on EKS or AKS. On most of those platforms it can get a short-lived OIDC identity token for that identity — minted on demand by the metadata server on GCP, projected into the pod on EKS and AKS. The caller then authenticates to your gateway without anyone creating, distributing, or rotating a new secret.

Zuplo has no dedicated GCP or Azure inbound JWT policy, and no inbound AWS SigV4 verification policy. The path that works is the generic Open ID JWT Auth policy pointed at the cloud's OIDC issuer, followed by Require User Claims to decide which of that issuer's identities may call the route.

How it works

  1. The caller asks its platform for an identity token for a fixed audience string.
  2. The JWT policy validates the signature against the issuer's JWKS, checks the iss and aud claims, and populates request.user.
  3. A claims policy allows only the identities on your allowlist and returns 403 to everything else.
  4. Later policies (rate limits, quotas, logging) group on the identity that step 2 established.

Step 3 isn't optional. Every Google customer's workloads share the issuer https://accounts.google.com, so on GCP the issuer check alone proves only that someone's Google service account made the call. The audience check and the claims allowlist are what narrow that to your workloads. A cluster issuer on EKS or AKS is already yours, but the claims rule is still what separates one namespace or service account from every other one on the cluster.

Prerequisites

  • A Zuplo project with at least one route.
  • A caller running on a platform that issues OIDC identity tokens for its own workload identity: Compute Engine, GKE, Cloud Run, or Cloud Build on GCP; EKS with IAM Roles for Service Accounts or EKS Pod Identity on AWS; AKS workload identity on Azure.
  • Access to set an environment variable on the project.

1/ Settle on a fixed audience

The gateway's job is to require one exact aud value. Settle on that value first, and store it as an environment variable assigned to every environment:

Code
GATEWAY_AUDIENCE=https://api.example.com/agents

Changing an environment variable requires a new deployment, so treat this value as stable configuration rather than something to tune per branch.

How much freedom you have in choosing it belongs to the caller's platform, not to the gateway. On GCP the caller passes any audience it likes as a query parameter, per call. On EKS and AKS the audience is fixed on the projected-token volume, so it is chosen once at deploy time — on AKS it defaults to api://AzureADTokenExchange. On a token issued by Microsoft Entra ID it has to be a registered Application ID URI, in the form api://<app-client-id>. Where the platform constrains the value, the gateway's audience has to match what the platform already issues rather than a value you invent.

Keep the audience decoupled from the deployment URL

Where you do get to choose, reusing the deployment URL as the audience looks tidy until the first preview build. Every branch deployment gets its own hostname, so every branch would need its own audience, its own caller-side configuration, and a redeploy to change. One constant that never varies across environments keeps a single caller configuration working everywhere.

2/ Mint a token in the caller

On GCP, a workload reads an identity token straight from the metadata server, with no SDK and no gcloud install required:

TerminalCode
AUDIENCE="https://api.example.com/agents" TOKEN=$(curl -s -H "Metadata-Flavor: Google" \ "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=${AUDIENCE}") curl -H "Authorization: Bearer ${TOKEN}" \ https://my-api-main-abc123.zuplo.app/agents/ping

The decoded payload of a service account identity token looks like this:

Code
{ "aud": "https://api.example.com/agents", "azp": "112010400000000710080", "email": "batch-runner@my-project.iam.gserviceaccount.com", "email_verified": true, "exp": 1745365618, "iat": 1745362018, "iss": "https://accounts.google.com", "sub": "112010400000000710080" }

Two claims carry the caller's identity, and they are not interchangeable. sub is the service account's immutable numeric unique ID. email is its address, which is what a reviewer recognizes when reading an allowlist six months later. Pick whichever your review process prefers, or match on either with an or rule.

Getting a token on a workstation

For local development, gcloud auth print-identity-token --audiences="$AUDIENCE" prints the same kind of token. Pass --audiences explicitly: without it you get a token with an audience the gateway is not configured to accept, and a 401 that looks like a signature problem. Add --include-email when printing a token for an impersonated service account, otherwise the token carries no email claim and an email allowlist rejects it.

Other clouds

PlatformWhere the caller gets the tokenissuerjwkUrl
GCP service accounts (Compute Engine, GKE, Cloud Run, Cloud Build)Metadata server identity endpoint, with the audience as a query parameterhttps://accounts.google.comhttps://www.googleapis.com/oauth2/v3/certs
AWS EKS (IAM Roles for Service Accounts, EKS Pod Identity)Projected service account token, with the audience set on the volumeThe cluster's OIDC issuer URLThe jwks_uri from the issuer's OpenID configuration document
AKS workload identityProjected service account token, with the audience set on the volumeThe cluster's OIDC issuer URL, from az aks show --query oidcIssuerProfile.issuerUrlThe jwks_uri from the issuer's OpenID configuration document

On both EKS and AKS the token the pod receives is the cluster's own projected service account token, so the subject is the Kubernetes service account — system:serviceaccount:<namespace>:<name> — and the issuer is the cluster, not the cloud's identity provider. A token issued by Microsoft Entra ID is a different thing: getting one means the workload first exchanges its projected token for an Entra token, its aud has to be a registered Application ID URI, and its sub is an object-ID GUID rather than anything human-readable. Verifying the cluster-issued token directly is the shorter path, and it is the one this guide follows.

For any issuer other than Google, read jwks_uri out of the issuer's /.well-known/openid-configuration document rather than guessing the path.

3/ Verify the token at the gateway

Code
{ "name": "workload-identity-auth", "policyType": "open-id-jwt-auth-inbound", "handler": { "export": "OpenIdJwtInboundPolicy", "module": "$import(@zuplo/runtime)", "options": { "issuer": "https://accounts.google.com", "audience": "$env(GATEWAY_AUDIENCE)", "jwkUrl": "https://www.googleapis.com/oauth2/v3/certs" } } }

Set audience. The option is optional in the schema and load-bearing in practice: without it, every service account identity token Google issues — for any project, aimed at any service — satisfies the policy.

Leave allowUnauthenticatedRequests unset. It defaults to false, which returns 401 for a missing, expired, or wrong-audience token. Setting it to true lets that request continue down the pipeline with no request.user attached, and everything that depends on an identity then degrades quietly rather than failing: rateLimitBy: "user" falls back to a single shared user-anonymous bucket, so every unauthenticated caller competes for one counter and any one of them can exhaust it for all the others. The route is then only as closed as the next policy or the handler makes it. Set it to true only in a deliberate fallback arrangement, where a second authentication policy runs after this one. See Multiple Auth Policies for that pattern.

4/ Pin which workloads may call the route

The JWT policy proves the caller holds a token from the issuer. The claims policy decides which callers that covers. Match on a claim the token actually carries: email exists on GCP service account identity tokens, and a projected Kubernetes service account token has no email claim at all.

Code
{ "name": "allow-known-workloads", "policyType": "require-user-claims-inbound", "handler": { "export": "RequireUserClaimsInboundPolicy", "module": "$import(@zuplo/runtime)", "options": { "rule": { "claim": "email", "in": [ "batch-runner@my-project.iam.gserviceaccount.com", "report-agent@my-project.iam.gserviceaccount.com" ] } } } }

This policy runs after an authentication policy, never instead of one. With no authenticated user on the request it returns 401; when the rule evaluates to false it returns 403 without echoing claim values, and writes the failing checks to the request log instead.

On EKS and AKS the subject is the Kubernetes service account, system:serviceaccount:<namespace>:<name>, so a namespace prefix pins every workload in one namespace at once:

Code
{ "rule": { "claim": "sub", "startsWith": "system:serviceaccount:agents:" } }

startsWith also covers AWS STS-issued tokens, whose subject is an assumed-role ARN such as arn:aws:sts::123456789012:assumed-role/my-role/<session> — a prefix match is the only workable rule there, because the trailing session name changes on every call. That is a different token from the projected service account token an EKS pod holds, so check which one your caller actually sends before writing the rule.

For namespaced claims, array-valued group claims, and and/or nesting, see the Require User Claims policy reference.

5/ Rate limit per identity

Code
{ "name": "rate-limit-per-workload", "policyType": "rate-limit-inbound", "handler": { "export": "RateLimitInboundPolicy", "module": "$import(@zuplo/runtime)", "options": { "rateLimitBy": "user", "requestsAllowed": 600, "timeWindowMinutes": 1 } } }

rateLimitBy: "user" groups on request.user.sub, which the authentication policy earlier in the pipeline populates, so nothing here is specific to JWTs. For Google tokens that subject is the numeric unique ID; set subPropertyName: "email" on the JWT policy if you would rather the subject, and therefore the rate limit bucket, carry the readable service account address.

Rate Limiting is available on every plan. Complex Rate Limiting, which counts several resources per request, is an Enterprise policy.

6/ Attach the policies to the route

Nothing above takes effect until the three policies are listed on the route, in pipeline order — authenticate, then authorize, then meter:

Code
{ "x-zuplo-route": { "policies": { "inbound": [ "workload-identity-auth", "allow-known-workloads", "rate-limit-per-workload" ] } } }

Per-request structured logging works on every plan through context.log.info and context.log.setLogProperties — see Logging — so the allowed identity can appear on the log line for the call it authorized. The Audit Log policy, which emits a CloudEvents audit trail, is an Enterprise policy on top of that.

7/ Verify it worked

Run all three cases against the deployed route:

TerminalCode
# 1. A token for the configured audience, from an allowlisted identity: 200. curl -o /dev/null -w "%{http_code}\n" \ -H "Authorization: Bearer ${TOKEN}" \ https://my-api-main-abc123.zuplo.app/agents/ping # 2. A token minted for a different audience: 401. OTHER=$(curl -s -H "Metadata-Flavor: Google" \ "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=https://api.example.com/other") curl -o /dev/null -w "%{http_code}\n" \ -H "Authorization: Bearer ${OTHER}" \ https://my-api-main-abc123.zuplo.app/agents/ping # 3. No token at all: 401. curl -o /dev/null -w "%{http_code}\n" \ https://my-api-main-abc123.zuplo.app/agents/ping

A token from a workload that is not on the allowlist returns 403 rather than 401 — the signature and audience were fine, the identity was not. That distinction is the fastest way to tell a caller-configuration problem from an authorization problem.

Limitations

  • No inbound AWS SigV4 verification policy. Verifying a SigV4-signed request means writing the verification in Custom Code. This matters on platforms where the ambient AWS credential is a signature rather than an OIDC token — a plain EC2 instance or a Lambda function, for example. The outbound direction has policies for it: AWS service auth and AWS federated auth are both Enterprise features and both in beta, and they are free to try in development. Neither sets an Authorization header on its own — SigV4 signs the exact final request, which is only known inside the handler, so the policies resolve the credentials and the awsLambdaHandler or your own code signs the request with AwsClient.fromContext(context).
  • No dedicated GCP or Azure inbound JWT policy. Zuplo ships provider-specific inbound JWT policies for identity providers — Auth0, Okta, Clerk, Firebase, Supabase, Cognito user pools — but none for cloud workload identity. The generic OIDC policy covers it, which means you supply issuer and jwkUrl yourself instead of picking a provider from a list.
  • Token lifetime belongs to the platform. Mint a token per call or hold it only in memory; never write one into configuration or a stored secret.

Additional resources

  • Authentication — every inbound authentication method Zuplo supports and how request.user is populated.
  • Rate limiting — the full set of identification strategies, including dynamic per-caller limits.
  • Request user — reading the authenticated identity and its claims in a handler.
  • Securing your backend — the other half of the request: how the gateway authenticates itself to your origin.
Edit this page
Last modified on July 29, 2026
Client mTLSURL Forward
On this page
  • How it works
  • Prerequisites
  • 1/ Settle on a fixed audience
  • 2/ Mint a token in the caller
    • Other clouds
  • 3/ Verify the token at the gateway
  • 4/ Pin which workloads may call the route
  • 5/ Rate limit per identity
  • 6/ Attach the policies to the route
  • 7/ Verify it worked
  • Limitations
  • Additional resources
JSON
JSON
JSON
JSON
JSON
JSON