---
title: "Using JWT and API Key Auth on the Same Route"
description: "Different consumers want different credentials on the same route. Validate JWT or API key on a single endpoint and hand downstream code one identity shape regardless of which arrived."
canonicalUrl: "https://zuplo.com/blog/2026/05/07/using-jwt-and-api-key-auth-on-the-same-route"
pageType: "blog"
date: "2026-05-07"
authors: "martyn"
tags: "authentication, api-keys, jwt"
image: "https://zuplo.com/og?text=Using%20JWT%20and%20API%20Key%20Auth%20on%20the%20Same%20Route"
---
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.

<CalloutAudience
  variant="bestFor"
  items={[
    `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.

<CalloutDoc
  title="API Key Authentication"
  description="Reference for the built-in policy that validates Zuplo-issued keys."
  href="https://zuplo.com/docs/policies/api-key-inbound"
  icon="code"
/>

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](https://zuplo.com/docs/policies/composite-inbound)
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.

![Three callers (web app, MCP client, data pipeline) all send Authorization Bearer to a single Zuplo dual-auth composite policy. The composite runs API Key Authentication and Open ID JWT Authentication in sequence, each allowing unauthenticated requests to fall through. A require-auth guard at the end rejects the request if neither validator populated request.user. Both successful paths merge into one identity that flows downstream to rate limits, monetization, and the handler.](/blog-images/2026-05-07-using-jwt-and-api-key-auth-on-the-same-route/diagram-1.png)

<CalloutDoc
  title="Composite Inbound Policy"
  description="Chain inbound policies together and run them as a single unit."
  href="https://zuplo.com/docs/policies/composite-inbound"
  icon="book"
/>

## Wiring it up

Four policies in `policies.json`: the two built-in validators, the guard, and
the composite that combines them.

```json
// config/policies.json
{
  "policies": [
    {
      "name": "api-key-auth",
      "policyType": "api-key-inbound",
      "handler": {
        "export": "ApiKeyInboundPolicy",
        "module": "$import(@zuplo/runtime)",
        "options": {
          "allowUnauthenticatedRequests": true,
          "cacheTtlSeconds": 60
        }
      }
    },
    {
      "name": "jwt-auth",
      "policyType": "open-id-jwt-auth-inbound",
      "handler": {
        "export": "OpenIdJwtInboundPolicy",
        "module": "$import(@zuplo/runtime)",
        "options": {
          "allowUnauthenticatedRequests": true,
          "issuer": "$env(JWT_ISSUER)",
          "audience": "$env(JWT_AUDIENCE)",
          "jwkUrl": "$env(JWT_JWKS_URL)"
        }
      }
    },
    {
      "name": "require-auth",
      "policyType": "custom-code-inbound",
      "handler": {
        "export": "default",
        "module": "$import(./modules/require-auth)"
      }
    },
    {
      "name": "dual-auth",
      "policyType": "composite-inbound",
      "handler": {
        "export": "CompositeInboundPolicy",
        "module": "$import(@zuplo/runtime)",
        "options": {
          "policies": ["api-key-auth", "jwt-auth", "require-auth"]
        }
      }
    }
  ]
}
```

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:

```ts
// modules/require-auth.ts
import { HttpProblems, ZuploContext, ZuploRequest } from "@zuplo/runtime";

export default async function (request: ZuploRequest, context: ZuploContext) {
  const sub = request.user?.sub;

  if (typeof sub !== "string" || sub.length === 0) {
    context.log.warn("Unauthenticated request reached auth gate", {
      url: request.url,
      method: request.method,
    });
    return HttpProblems.unauthorized(request, context, {
      detail: "Authentication required.",
    });
  }

  return request;
}
```

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:

```json
// config/routes.oas.json
{
  "openapi": "3.1.0",
  "info": { "title": "My Zuplo API", "version": "1.0.0" },
  "paths": {
    "/me": {
      "x-zuplo-path": { "pathMode": "open-api" },
      "get": {
        "summary": "Who Am I?",
        "x-zuplo-route": {
          "corsPolicy": "none",
          "handler": {
            "export": "default",
            "module": "$import(./modules/forward-slash-me)",
            "options": {}
          },
          "policies": {
            "inbound": ["dual-auth"]
          }
        },
        "operationId": "who-am-i"
      }
    }
  }
}
```

Both credential types arrive in `Authorization: Bearer`, matching Zuplo's
default. The composite handles the rest.

<CalloutSample
  title="JWT or API Key Multi-Auth Example"
  description="A complete working example of the composite-inbound dual-auth pattern. Deploy to your Zuplo account or run locally."
  deployUrl="https://zuplo.com/examples/api-key-jwt-multi-auth"
  repoUrl="https://github.com/zuplo/zuplo/tree/main/examples/api-key-jwt-multi-auth"
  localCommand="npx create-zuplo-api --example api-key-jwt-multi-auth"
/>

<CalloutVideo
  variant="card"
  title="JWT and API Key Auth on the Same Route"
  description="Watch the dual-auth composite policy in action: one route accepting both credential types and producing a single identity downstream."
  videoUrl="https://youtu.be/DExrMb0wILA"
  thumbnailUrl="https://img.youtube.com/vi/DExrMb0wILA/maxresdefault.jpg"
/>

## What downstream code sees

The same handler, regardless of which credential was used:

```ts
// modules/forward-slash-me.ts
import { ZuploRequest } from "@zuplo/runtime";

export default async function (request: ZuploRequest) {
  return new Response(JSON.stringify(request.user, null, 2), {
    headers: { "content-type": "application/json" },
  });
}
```

A request with `Authorization: Bearer zpka_...` returns the identity built from
the API key consumer record:

```json
{
  "sub": "data-pipeline-prod",
  "data": { "plan": "pro" }
}
```

A request with `Authorization: Bearer eyJ...` returns the identity built from
the JWT claims:

```json
{
  "sub": "user_abc123",
  "data": { "plan": "pro", "iat": 1730000000 }
}
```

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.

<CalloutDoc
  title="Open ID JWT Authentication"
  description="Reference for the built-in policy that validates JWTs against an IdP's JWKS."
  href="https://zuplo.com/docs/policies/open-id-jwt-auth-inbound"
  icon="lightning"
/>

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