Zuplo
authentication

Using JWT and API Key Auth on the Same Route

Martyn DaviesMartyn Davies
May 7, 2026
6 min read

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.

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.

Best for:
  • 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.

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.

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.

JSONjson
// 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:

TypeScriptts
// 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:

JSONjson
// 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.

Try it yourself

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.

DeployView on GitHub
JWT and API Key Auth on the Same Route
Video Tutorial

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:

TypeScriptts
// 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:

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

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

JSONjson
{
  "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.

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.