The web app authenticates with OAuth. An MCP client shows up next carrying a JWT for whoever is signed into it. The customer’s data pipeline still wants an API key. All three need to call the same endpoint, and the usual answer is “pick one and tell the other teams to deal with it.” It’s wrong, and the fix is a small bit of config plus a five-line guard.
- Human users on web apps and partner integrations authenticating with JWT or OAuth
- Users who have wired your API into an MCP client, which signs them in via OAuth and presents a JWT
- Direct API consumers (data pipelines, CI, agents you've issued keys to) using API keys
Why APIs need both JWT and API keys
Two consumer profiles want two different credentials, and both are right.
JWT and OAuth fit user-facing apps. The browser has a session, the SDK refreshes tokens, the IdP holds the signing key. Short-lived, revocable, standardised. That’s what your web app and partner integrations want.
API keys fit machine-to-machine. A key is one string in a header, easy to test with curl, easy to drop in a CI environment variable, easy to rotate without a refresh dance. That’s what a customer’s nightly data pipeline wants, and what you’d issue to an agent you control end-to-end.
API Key Authentication
Reference for the built-in policy that validates Zuplo-issued keys.
MCP didn’t invent the dual-auth squeeze, but it added another OAuth-bearing client to the mix. When someone wires your API into an MCP-connected tool, the MCP client signs them in via OAuth and presents the resulting JWT to your gateway.
From the gateway’s perspective that’s a normal JWT-authenticated request, the same shape as a logged-in browser session. The route accepts it without caring that the trip went through an MCP client, and the identity it builds is the same one you’d see for that user anywhere else.
Forcing a choice pushes complexity onto the consumer. They either build an OAuth client into a cron job (painful) or hand-roll a “pretend I’m a service account” flow on top of your user auth (worse). The gateway should accept both and normalise downstream.
The composite pattern
Zuplo’s built-in policies already know how to validate API keys and JWTs. The
trick is letting both run on the same route and accepting whichever matches. A
Composite Inbound policy
chains them together, each configured with allowUnauthenticatedRequests: true
so the chain falls through when a given credential type isn’t present. A small
custom-code guard at the end rejects the request if neither validator populated
request.user.
That’s three lines of TypeScript and four entries in policies.json. No
dispatcher, no shape checks, no JWKS handling. The built-in policies do all of
it, and request.user ends up populated the same way regardless of which
credential the caller used.

Composite Inbound Policy
Chain inbound policies together and run them as a single unit.
Wiring it up
Four policies in policies.json: the two built-in validators, the guard, and
the composite that combines them.
Two things matter about that config. First, allowUnauthenticatedRequests: true
on both validators is what makes the chain work: each policy validates only the
credential type it understands, then lets the request continue if it didn’t find
one. Second, the composite runs them in order, so the final require-auth only
sees a request after both validators have had a turn.
The guard is short:
That’s the whole custom code surface. The validators populate request.user on
success; the guard rejects anything that came out the other side without a
sub.
The route only references the composite:
Both credential types arrive in Authorization: Bearer, matching Zuplo’s
default. The composite handles the rest.
JWT or API Key Multi-Auth Example
A complete working example of the composite-inbound dual-auth pattern. Deploy to your Zuplo account or run locally.

JWT and API Key Auth on the Same Route
Watch the dual-auth composite policy in action: one route accepting both credential types and producing a single identity downstream.
What downstream code sees
The same handler, regardless of which credential was used:
A request with Authorization: Bearer zpka_... returns the identity built from
the API key consumer record:
A request with Authorization: Bearer eyJ... returns the identity built from
the JWT claims:
Same sub shape. Same plan field if you mirror the metadata between IdP
claims and API key consumer metadata. Rate limits keyed on request.user.sub
work identically for both. Monetization meters attribute usage to one consumer
record regardless of how they authenticated.
Hardening before you ship this
Most of the hardening that used to live in custom code is now delegated to the built-in policies, but a few things still need attention.
The require-auth guard is non-optional.
allowUnauthenticatedRequests: true on each validator means the chain will let
an unauthenticated request through if you forget the guard. Keep it as the last
entry in the composite’s policies array, and don’t reorder.
Set issuer and audience on the JWT policy. Without them, any JWT signed
by your IdP for any audience validates against your gateway. The config above
includes both, sourced from environment variables. Don’t drop them.
Open ID JWT Authentication
Reference for the built-in policy that validates JWTs against an IdP's JWKS.
Use a remote JWKS URL, not an inline secret. The IdP holds the private key,
your gateway only verifies. If the gateway config leaks, attackers can’t mint
tokens. The jwkUrl field above points at the IdP’s published JWKS endpoint.
Log which path authenticated. When something goes wrong, “this request was
authenticated” isn’t enough. Add a small inbound policy after dual-auth that
logs request.user.sub and the credential shape, or stamp an authMethod field
on request.user.data from a thin wrapper if you want it visible to downstream
handlers.
When not to use this
If your route only ever sees one credential type, don’t build a composite. Use the appropriate built-in policy directly. The composite is for routes that legitimately accept both.
If you have multiple JWT issuers (Auth0 for partners, Azure AD for employees),
the same idea applies but you’d add a second open-id-jwt-auth-inbound entry to
the composite, each pointing at its own JWKS, both with
allowUnauthenticatedRequests: true. The guard at the end stays the same.
Otherwise, ship the composite. The web app, the MCP client, and the data pipeline all hit the same route, your handler code only gets written once, and the next time someone asks “can we use the other auth method on this endpoint?” the answer is already in production.
