# Upstream Credentials

Every request that passes through Zuplo involves two separate authentication
decisions. The caller authenticates to the gateway, and the gateway
authenticates to whatever it calls next. The two rarely use the same credential,
and on most routes they don't even use the same scheme: a client might present a
Zuplo API key while the upstream expects an AWS SigV4 signature, a Google ID
token, or a vendor API key in a query string.

[Authentication](./authentication.mdx) covers the first decision. This page
covers the second — which credential the gateway attaches on the way out, which
policy attaches it, and where the credential itself lives.

## Two directions, one request

An upstream credential is attached in the middle of the request pipeline, after
the caller's identity is known and before the request leaves for the origin.

<Diagram height="h-96">
  <DiagramNode id="caller">Caller</DiagramNode>
  <DiagramGroup id="zuplo" label="Zuplo API Gateway">
    <DiagramNode id="verify" variant="zuplo">
      Verify caller credential
    </DiagramNode>
    <DiagramNode id="enforce" variant="zuplo">
      Authorize, rate limit, log
    </DiagramNode>
    <DiagramNode id="attach" variant="zuplo">
      Attach upstream credential
    </DiagramNode>
  </DiagramGroup>
  <DiagramNode id="upstream">Upstream</DiagramNode>
  <DiagramEdge from="caller" to="zuplo" />
  <DiagramEdge from="verify" to="enforce" fromSide="bottom" toSide="top" />
  <DiagramEdge from="enforce" to="attach" fromSide="bottom" toSide="top" />
  <DiagramEdge from="zuplo" to="upstream" />
</Diagram>

The ordering is what makes the pattern useful. By the time the credential is
attached, `request.user` is populated, so the same request can be authorized
with [Require User Claims](../policies/require-user-claims-inbound.mdx), rate
limited per identity, and logged with the caller's subject — and the credential
the upstream receives is one the caller never held.

That changes what a leaked caller credential is worth. A revoked gateway API key
takes its holder's access with it, and the upstream secret exists in one place
instead of one place per caller. It does not change what a caller with
legitimate access can do. Deciding whether a call _should_ happen is
authorization, rate limiting, and logging — separate policies, on the same
request.

## Why every credential policy ends in `-inbound`

Zuplo's pipeline has exactly two policy stages.
[Inbound policies](../articles/policies.mdx) run before the handler sends the
request upstream. Outbound policies run on the response, on its way back to the
caller.

Attaching an upstream credential happens before the request leaves, so every
credential-injection policy is an inbound policy and carries the `-inbound`
suffix — including the ones whose entire purpose is the outbound call, like
`upstream-gcp-service-auth-inbound`. The suffix names the pipeline stage, not
the direction of the credential. It is not a typo.

The `-outbound` policies work on responses. That is where
[Secret Masking](../policies/secret-masking-outbound.mdx) belongs: it redacts
credentials that leaked into a response body before the caller — or an LLM
reading the response — ever sees them.

## Choosing a policy

| The upstream expects                                                 | Policy                                                                                             | Availability      | Pick it when                                                                                                                                                                                  |
| -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| A static key or token in one header                                  | [`set-upstream-api-key-inbound`](../policies/set-upstream-api-key-inbound.mdx)                     | All plans         | One secret, one header. The common case, and the default header is `Authorization`.                                                                                                           |
| Several headers, or a value composed from more than one variable     | [`set-headers-inbound`](../policies/set-headers-inbound.mdx)                                       | All plans         | The upstream wants a non-standard header name, two headers, or a key plus a tenant identifier.                                                                                                |
| A GCP ID token or a scoped Google access token, from a stored SA key | [`upstream-gcp-service-auth-inbound`](../policies/upstream-gcp-service-auth-inbound.mdx)           | Enterprise        | Cloud Run, Cloud Functions, or GKE behind IAP (`audience`), or a Google API that needs OAuth `scopes`.                                                                                        |
| A GCP ID token, with no service-account key stored anywhere          | [`upstream-gcp-federated-auth-inbound`](../policies/upstream-gcp-federated-auth-inbound.mdx)       | Enterprise        | The same IAM-protected services, using Workload Identity Federation instead of a stored key. Audience-bound ID tokens only.                                                                   |
| A self-signed JWT accepted by Cloud Endpoints or ESPv2               | [`upstream-gcp-jwt-inbound`](../policies/upstream-gcp-jwt-inbound.mdx)                             | Enterprise        | Rarely. The policy's own page steers you to GCP service auth in most cases.                                                                                                                   |
| An access token from any OAuth 2.0 client-credentials endpoint       | `upstream-oauth-client-credentials-inbound`                                                        | All plans         | An internal authorization server, or any SaaS vendor that issues client-credentials tokens. Set `tokenUrl`, `clientId`, `clientSecret`, and `scope` or `audience` if the provider needs them. |
| An Entra ID (Azure AD) access token from the client-credentials flow | [`upstream-azure-ad-service-auth-inbound`](../policies/upstream-azure-ad-service-auth-inbound.mdx) | Enterprise        | Azure App Service, Azure Functions, or any Entra-protected resource. It builds the Entra token URL for you from a tenant id.                                                                  |
| A SigV4-signed request, credentials from a stored IAM key pair       | [`upstream-aws-service-auth-inbound`](../policies/upstream-aws-service-auth-inbound.mdx)           | Enterprise · Beta | Lambda or another AWS service, optionally through an STS AssumeRole. Read [AWS is different](#aws-is-different) first.                                                                        |
| A SigV4-signed request, with no AWS keys stored anywhere             | [`upstream-aws-federated-auth-inbound`](../policies/upstream-aws-federated-auth-inbound.mdx)       | Enterprise · Beta | The same, using `AssumeRoleWithWebIdentity` instead of a stored key pair.                                                                                                                     |
| A Firebase admin token                                               | [`upstream-firebase-admin-auth-inbound`](../policies/upstream-firebase-admin-auth-inbound.mdx)     | All plans         | Firestore or another Firebase service called with service-account permissions.                                                                                                                |
| A Firebase token scoped to one end user                              | [`upstream-firebase-user-auth-inbound`](../policies/upstream-firebase-user-auth-inbound.mdx)       | All plans         | Firebase should apply that user's own permissions. `userId` can be read from `request.user`.                                                                                                  |
| A JWT proving the call came from your gateway                        | [`upstream-zuplo-jwt-auth-inbound`](../policies/upstream-zuplo-jwt-auth-inbound.mdx)               | Enterprise        | Your own upstream, with no third-party identity provider in the picture. Claims can be copied from the caller.                                                                                |
| Anything else                                                        | [`custom-code-inbound`](../policies/custom-code-inbound.mdx)                                       | All plans         | Query-string keys, HMAC signatures, or picking the credential from the caller's identity.                                                                                                     |

:::info{title="Enterprise policies are free to try in development"}

The GCP, Azure AD, AWS, and Zuplo-JWT upstream policies above are Enterprise
features for production traffic, and free to use on any plan for development.
The two AWS policies are also in beta and may change in non-backward-compatible
ways. The same applies to two policies this pattern often pairs with:
[`audit-log-inbound`](../policies/audit-log-inbound.mdx) and
[`complex-rate-limit-inbound`](../policies/complex-rate-limit-inbound.mdx) are
Enterprise, while [`rate-limit-inbound`](../policies/rate-limit-inbound.mdx) and
[`quota-inbound`](../policies/quota-inbound.mdx) are not.

:::

Two policies in the catalog look like they belong in the table and don't.
`set-headers-outbound` sets headers on the **response**, so it can't
authenticate an upstream call. `custom-code-outbound` runs after the response
arrives — useful for reshaping a body, not for signing a request.

### AWS is different

The two AWS policies do not set an `Authorization` header. SigV4 signs the exact
final request — method, path, query, headers, body hash — so a header computed
earlier in the pipeline would be wrong by the time the request left.

Instead, both policies resolve AWS credentials and register them on the request
context. Something later in the pipeline signs with them:

- The [AWS Lambda handler](../handlers/aws-lambda.mdx), which does it for you.
- Your own code, via `AwsClient.fromContext(context)` in a
  [custom code policy](../policies/custom-code-inbound.mdx) or
  [custom handler](../handlers/custom-handler.mdx).

If a route uses one of these policies and the upstream returns 403, check that
something is actually consuming the resolved credentials. The policy alone does
not change the outgoing request.

### MCP routes

MCP routes have their own credential broker.
[`mcp-token-exchange-inbound`](../policies/mcp-token-exchange-inbound.mdx)
resolves gateway-managed upstream credentials in three modes: `user-oauth`
(per-user OAuth federation, tokens stored encrypted at rest and keyed to the
user's subject), `shared-oauth` (one gateway-wide grant an administrator
connects once), and `id-jag` (Cross-App Access token exchange, built on an
active IETF draft rather than a finalized RFC). Per-user upstream API keys are
not part of this set.

See
[Per-user OAuth to upstream MCP servers](../mcp-gateway/auth/upstream-oauth.mdx)
for how the outbound surface works, and
[Connect an upstream that uses an API key](../mcp-gateway/how-to/connect-upstream-api-key.mdx)
for upstreams with no OAuth at all.

## Where the credential lives

Upstream secrets belong in
[environment variables](../articles/environment-variables.mdx), referenced from
policy configuration with `$env(VAR_NAME)` and from code with
`environment.VAR_NAME`. Values are encrypted at rest; values marked as secrets
are write-only, so they can't be read back after they're set.

Two behaviors surprise people:

- **A changed value needs a new deployment.** Variables are applied to an
  environment at deploy time. Updating a credential without redeploying leaves
  the old value in service.
- **The CLI and the Developer API set variables per branch.**
  [`zuplo variable create`](../cli/variable-create.mdx) and
  [`zuplo variable update`](../cli/variable-update.mdx) both take a `--branch`
  flag and create a record for that branch alone.

:::caution{title="Set shared credentials in the Portal, not the CLI"}

There is no all-environments assignment in the CLI or the Developer API. Only
the
[environment-variable editor](../articles/environment-variables.mdx#environment-variable-editor)
in the Zuplo Portal can apply one variable to several environments at once —
open **Settings → Environment Variables** in your project and select every
environment the credential belongs to.

A credential seeded per branch looks correct in the environment where it was
created and is simply absent everywhere else. Nothing fails at build or deploy
time; the first sign of trouble is a 401 or 403 from the upstream on live
traffic, which can go unnoticed for days on a low-volume route.

:::

The strongest option is to store nothing. Both federated policies
([GCP](../policies/upstream-gcp-federated-auth-inbound.mdx),
[AWS](../policies/upstream-aws-federated-auth-inbound.mdx)) exchange the
gateway's own OIDC identity for short-lived cloud credentials, so there is no
long-lived key to leak, rotate, or seed into an environment.

## Related

- [Securing your backend](../articles/securing-your-backend.mdx) — the six ways
  to make sure only your gateway can reach your origin.
- [Secure a GCP backend with Zuplo upstream auth](../articles/gke-with-upstream-auth-policy.mdx)
  — an end-to-end walkthrough with GKE and Identity-Aware Proxy.
- [Authentication](./authentication.mdx) — the inbound half of the request.
- [Environment variables](../articles/environment-variables.mdx) — syntax,
  environments, and deployment behavior.
- [Policies](../articles/policies.mdx) — how the inbound and outbound stages fit
  together.
