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
- The caller asks its platform for an identity token for a fixed audience string.
- The JWT policy validates the signature against the issuer's JWKS, checks the
issandaudclaims, and populatesrequest.user. - A claims policy allows only the identities on your allowlist and returns
403to everything else. - 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
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:
Code
The decoded payload of a service account identity token looks like this:
Code
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
| Platform | Where the caller gets the token | issuer | jwkUrl |
|---|---|---|---|
| GCP service accounts (Compute Engine, GKE, Cloud Run, Cloud Build) | Metadata server identity endpoint, with the audience as a query parameter | https://accounts.google.com | https://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 volume | The cluster's OIDC issuer URL | The jwks_uri from the issuer's OpenID configuration document |
| AKS workload identity | Projected service account token, with the audience set on the volume | The cluster's OIDC issuer URL, from az aks show --query oidcIssuerProfile.issuerUrl | The 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
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
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
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
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
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:
Code
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
Authorizationheader on its own — SigV4 signs the exact final request, which is only known inside the handler, so the policies resolve the credentials and theawsLambdaHandleror your own code signs the request withAwsClient.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
issuerandjwkUrlyourself 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.useris 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.