# 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](../policies/open-id-jwt-auth-inbound.mdx) policy pointed at
the cloud's OIDC issuer, followed by
[Require User Claims](../policies/require-user-claims-inbound.mdx) 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](./environment-variables.mdx) 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:

```
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.

:::caution{title="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:

```bash
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:

```json
{
  "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.

:::tip{title="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

```json title="config/policies.json"
{
  "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](./multiple-auth-policies.mdx) 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.

```json title="config/policies.json"
{
  "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:

```json
{
  "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](../policies/require-user-claims-inbound.mdx) policy
reference.

## 5/ Rate limit per identity

```json title="config/policies.json"
{
  "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](../policies/rate-limit-inbound.mdx) is available on every plan.
[Complex Rate Limiting](../policies/complex-rate-limit-inbound.mdx), 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:

```json title="config/routes.oas.json (excerpt)"
{
  "x-zuplo-route": {
    "policies": {
      "inbound": [
        "workload-identity-auth",
        "allow-known-workloads",
        "rate-limit-per-workload"
      ]
    }
  }
}
```

:::note

Per-request structured logging works on every plan through `context.log.info`
and `context.log.setLogProperties` — see [Logging](./logging.mdx) — so the
allowed identity can appear on the log line for the call it authorized. The
[Audit Log](../policies/audit-log-inbound.mdx) 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:

```bash
# 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](../policies/custom-code-inbound.mdx). 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](../policies/upstream-aws-service-auth-inbound.mdx) and
  [AWS federated auth](../policies/upstream-aws-federated-auth-inbound.mdx) 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](../concepts/authentication.mdx) — every inbound
  authentication method Zuplo supports and how `request.user` is populated.
- [Rate limiting](../rate-limiting/getting-started.mdx) — the full set of
  identification strategies, including dynamic per-caller limits.
- [Request user](../programmable-api/request-user.mdx) — reading the
  authenticated identity and its claims in a handler.
- [Securing your backend](./securing-your-backend.mdx) — the other half of the
  request: how the gateway authenticates itself to your origin.
