Zuplo
API Authentication

Securing APIs at the Gateway: OAuth 2.0 Client Credentials and JWT Validation

Nate TottenNate Totten
August 11, 2026
11 min read

Learn how to enforce OAuth 2.0 client credentials and validate JWTs at the API gateway — issuer, audience, signature, scopes, and claims-based authorization.

Most API security incidents are not clever cryptographic attacks. They are a route that shipped without an auth policy attached, or a token that was checked for a valid signature but never for its intended audience. The fix is boring and architectural: make authentication a property of the edge your traffic passes through, not something each service remembers to implement.

This guide covers what that looks like in practice — choosing between API keys, OAuth 2.0, and JWTs; running the client credentials grant for machine-to-machine traffic; validating tokens correctly at the edge; and enforcing scopes and claims once a token is verified. The examples use Zuplo, but the validation rules apply to any gateway.

Auth models at the gateway: API keys vs. OAuth 2.0 vs. JWT

These three are not competing options ranked by strength. They answer different questions, and larger APIs usually run more than one.

API keys

An API key is a long-lived opaque string that identifies a consumer — a customer, a partner, a script. The gateway looks the key up to decide whether it is valid, which means key validation is stateful.

Keys are the right choice when you are onboarding third-party developers who need a credential they can paste into a script and forget about. They are the wrong choice when you need short-lived credentials, per-user identity, or granular delegation. See how to implement API key authentication for the operational side, and API key rotation and lifecycle management for the part teams usually skip.

OAuth 2.0

OAuth 2.0 is not a token format. It is a framework describing how a client obtains an access token from an authorization server, and it defines several grants for different situations — authorization code with PKCE for apps acting on behalf of a human, client credentials for services acting as themselves.

The important consequence for gateway design: OAuth 2.0 describes how the token is issued, which is your identity provider’s job. Your gateway’s job starts when that token shows up on a request. For a deeper treatment of the grants themselves, see securing your API with OAuth 2.0.

JWT

A JWT is a token format — a base64url-encoded header, payload, and signature. Because the claims travel inside the token and the signature proves they were not modified, a gateway can verify a JWT using only a public key, with no round-trip to the issuer. That is what makes JWTs the natural fit for edge validation.

An OAuth 2.0 access token is frequently a JWT, but not always: some providers issue opaque tokens that must be introspected instead. If you are on Curity, Zuplo’s Curity Phantom Token policy handles that pattern — it introspects the opaque token and caches the result for a configurable duration.

If the distinction between OAuth 2.0, OIDC, and plain OpenID is still fuzzy, this comparison untangles them. And for the difference between proving who a caller is and deciding what they may do, see authentication vs. authorization.

Choosing quickly

  • Third-party developers self-serving access to a public API → API keys.
  • Services, cron jobs, and CI pipelines calling your API → OAuth 2.0 client credentials, validated as a JWT.
  • An app acting on behalf of a logged-in user → authorization code with PKCE, validated as a JWT at the gateway.

The OAuth 2.0 client credentials grant for machine-to-machine APIs

Client credentials is the simplest OAuth 2.0 grant because there is no human in the flow. There is no redirect, no consent screen, and no refresh token — the client just asks for a token whenever it needs one.

The client authenticates itself to your identity provider’s token endpoint:

Terminalbash
curl --request POST 'https://my-tenant.us.auth0.com/oauth/token' \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'grant_type=client_credentials' \
  --data-urlencode 'client_id=YOUR_CLIENT_ID' \
  --data-urlencode 'client_secret=YOUR_CLIENT_SECRET' \
  --data-urlencode 'audience=https://api.example.com'

The response contains the access token:

JSONjson
{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Ii4uLiJ9...",
  "token_type": "Bearer",
  "expires_in": 86400
}

That expires_in is Auth0’s 24-hour default, and it is longer than it should be. Lower it — see common mistakes below.

The client then presents it on every API call:

Terminalbash
curl 'https://api.example.com/v1/orders' \
  --header 'Authorization: Bearer eyJhbGciOiJSUzI1NiIs...'

Two design notes matter more than the mechanics.

The audience parameter is what makes the token scoped to your API. Ask for https://api.example.com and the resulting token carries that value in its aud claim. If your gateway does not check aud, a token minted for a completely different API in the same tenant will sail through — which is the single most common JWT validation gap.

Clients should cache tokens, not fetch one per request. A token valid for an hour fetched on every call turns your identity provider into a hot dependency on your request path and will eventually hit its rate limits. Cache the token in memory and refresh it shortly before expires_in elapses.

Note what the gateway does not do here: it does not issue these tokens. Your identity provider owns the token endpoint. The gateway is the resource server that verifies the result.

Validating JWTs at the edge

Validation is where implementations quietly go wrong, because a token with a valid signature looks fine. A correct check covers five things:

  1. Signature — verified against the issuer’s published public key, fetched from its JWKS endpoint.
  2. Issuer (iss) — matches the authorization server you actually trust.
  3. Audience (aud) — names your API, so a token for another service is rejected.
  4. Expiry (exp) — and nbf if present.
  5. Algorithm — the token’s own alg header must never decide how it gets verified. This one is a property of the validating implementation rather than something you configure; see common mistakes for why it matters.

In Zuplo this is a policy in policies.json rather than code. The generic JWT Auth policy works with any OpenID-compliant provider:

JSONjson
{
  "name": "jwt-auth",
  "policyType": "open-id-jwt-auth-inbound",
  "handler": {
    "export": "OpenIdJwtInboundPolicy",
    "module": "$import(@zuplo/runtime)",
    "options": {
      "issuer": "$env(AUTH_ISSUER)",
      "audience": "$env(AUTH_AUDIENCE)",
      "jwkUrl": "https://my-tenant.us.auth0.com/.well-known/jwks.json"
    }
  }
}

The $env(...) syntax pulls values from environment variables, which matters because issuer and audience usually differ between development, staging, and production. Hardcoding them is how a staging token ends up accepted in production.

Three options are worth knowing about:

  • audience — optional in the schema and load-bearing in practice. Leave it out and the policy will accept any token your issuer signed, including tokens minted for a completely different API in the same tenant. Always set it.
  • jwkUrl or secret — one of the two is required. Use jwkUrl for asymmetric signing (RS256/ES256), where the gateway needs only the public key. secret is for symmetric signing, which means the gateway holds the same key that mints tokens.
  • allowUnauthenticatedRequests — defaults to false, so failed authentication returns 401 automatically. Set it to true only when a route genuinely serves both authenticated and anonymous traffic, or when you are chaining several auth policies on one route.

Attach the policy to a route’s inbound array:

JSONjson
{
  "path": "/v1/orders",
  "methods": ["GET"],
  "handler": {
    "module": "$import(./modules/orders)",
    "export": "getOrders"
  },
  "policies": {
    "inbound": ["jwt-auth"]
  }
}

Once the policy passes, the decoded token is available on the request. The sub claim lands on request.user.sub, and the remaining claims on request.user.data:

TypeScripttypescript
import { ZuploContext, ZuploRequest } from "@zuplo/runtime";

export async function getOrders(request: ZuploRequest, context: ZuploContext) {
  const userId = request.user?.sub;
  const claims = request.user?.data;

  context.log.info(`Request from ${userId}`);

  return { orders: [], requestedBy: userId, tenant: claims?.tenant_id };
}

By default these policies leave the Authorization header intact so it is forwarded upstream. That is useful while you are migrating auth responsibility into the gateway, but Zuplo’s OAuth authentication docs note that validating the same access token at both the gateway and the backend isn’t recommended. Pick one enforcement point — the gateway — and let downstream services trust the identity it passes along.

Enforcing scopes and claims after authentication

A valid token answers “who is calling?” It does not answer “may they do this?” Those are separate policies that run after authentication.

Scope validation

The JWT scopes policy requires that a validated token carry specific scopes:

JSONjson
{
  "name": "require-orders-read",
  "policyType": "jwt-scopes-inbound",
  "handler": {
    "export": "JWTScopeValidationInboundPolicy",
    "module": "$import(@zuplo/runtime)",
    "options": {
      "scopes": ["read:orders"]
    }
  }
}

Scopes are coarse by design — they describe capabilities, not resources. Define them per operation (read:orders, write:orders) rather than per role, so adding a role later does not mean reissuing every token. See custom scopes in OAuth for naming conventions that survive growth.

Claims-based authorization

When the decision depends on claim values rather than capabilities, the require user claims policy evaluates a rule against the authenticated user’s claims:

JSONjson
{
  "name": "require-internal-callers",
  "policyType": "require-user-claims-inbound",
  "handler": {
    "module": "$import(@zuplo/runtime)",
    "export": "RequireUserClaimsInboundPolicy",
    "options": {
      "rule": {
        "or": [
          {
            "claim": "sub",
            "in": ["ci-deployer@example.iam.gserviceaccount.com"]
          },
          { "claim": "email", "startsWith": "svc-" }
        ]
      }
    }
  }
}

The rule supports eq for exact matches, in for allowlists, and startsWith for prefixes, combined with and/or nested up to three levels deep. The two failure modes are distinct and worth understanding: no authenticated user at all returns 401 Unauthorized, while an authenticated user who fails the rule returns 403 Forbidden.

This is genuinely useful for pinning a sensitive route to a known set of service accounts — a check that would otherwise live as a hand-rolled allowlist in application code.

Order matters when you chain these. Authentication first, then scopes, then claims:

JSONjson
{
  "policies": {
    "inbound": ["jwt-auth", "require-orders-read", "require-internal-callers"]
  }
}

For authorization models that outgrow scopes and claim matching — per-resource ownership, relationship-based rules, external policy decision points — see fine-grained API authorization.

Integrating an external identity provider

You almost certainly should not build your own authorization server. The integration surface is small: your gateway needs the issuer URL, the JWKS endpoint, and the expected audience. The first two are published in any OIDC-compliant provider’s /.well-known/openid-configuration document as issuer and jwks_uri. The audience is not — it is the API identifier you choose when you register the API with your identity provider (an API Identifier in Auth0, api://my-api in Okta), so don’t go looking for it in the discovery document.

Zuplo ships provider-specific policies that pre-fill the details for Auth0, Okta, Clerk, AWS Cognito, Firebase, Supabase, and PropelAuth. Anything else OpenID-compliant uses the generic JWT policy above — the provider list is not a compatibility boundary, just a convenience.

Beyond bearer tokens, the authentication overview covers Basic Auth, mTLS, and LDAP (an Enterprise-plan policy) for cases where a bearer token is not the right credential.

Accepting more than one credential type

If a route needs to accept, say, both a JWT and an API key — a common shape while you migrate — stacking two auth policies requires one non-obvious step. Each policy validates independently, so with the default allowUnauthenticatedRequests: false the first one to see a credential it does not recognize returns 401 before the second ever runs. Zuplo’s docs on handling multiple authentication policies are explicit: “In the case of multiple policies, this setting must be true.”

That leaves the route open, so you close it with a small custom policy at the end of the chain that rejects anything no validator authenticated:

TypeScripttypescript
import { HttpProblems, ZuploContext, ZuploRequest } from "@zuplo/runtime";

export default async function (request: ZuploRequest, context: ZuploContext) {
  if (!request.user?.sub) {
    return HttpProblems.unauthorized(request, context);
  }
  return request;
}

Two JWT issuers are a harder case, because both policies validate the same token value rather than looking at different credentials. The docs recommend a different approach there: write a single custom policy that reads the token’s iss claim first, then applies the validation appropriate to that issuer.

Custom authorization logic in TypeScript

Configuration covers most cases. When a decision needs logic the built-in policies do not express — comparing two claims against each other, calling an internal service, applying a time window — Zuplo lets you write it as a custom code policy that runs at the edge alongside the declarative ones.

An inbound policy handler receives the request and returns either a ZuploRequest to continue the pipeline or a Response to short-circuit it:

This example enforces that the tenant in a token’s claims matches the tenant in the URL — a check that needs both halves of the request, so no amount of configuration expresses it. Assume a route like /v1/tenants/:tenantId/orders:

TypeScripttypescript
import { HttpProblems, ZuploContext, ZuploRequest } from "@zuplo/runtime";

interface TenantPolicyOptions {
  claimName: string;
}

export async function requireMatchingTenant(
  request: ZuploRequest,
  context: ZuploContext,
  options: TenantPolicyOptions,
  policyName: string,
) {
  // The JWT policy has already validated the token and populated request.user.
  const tokenTenant = request.user?.data?.[options.claimName];
  const pathTenant = request.params.tenantId;

  if (typeof tokenTenant !== "string" || tokenTenant !== pathTenant) {
    context.log.warn(`${policyName}: tenant mismatch for ${request.user?.sub}`);
    // Returning a Response short-circuits the pipeline.
    return HttpProblems.forbidden(request, context);
  }

  // Returning the request continues to the next policy or handler.
  return request;
}

Three things to notice. This policy runs after the JWT policy in the inbound array, so it can trust request.user without re-verifying anything. The claim value is checked with typeof before comparison, because claims come off the token untyped. And the denial goes through HttpProblems, which returns an RFC 7807 problem response that says “Forbidden” and nothing more — leaking why a check failed gives an attacker a free oracle for probing your authorization rules.

Common mistakes

The failures worth guarding against are mostly the same handful:

Not validating aud. A token minted for a different API in the same identity provider tenant is a perfectly valid token. Without an audience check, it is also a valid token for your API.

Trusting the token’s alg header. The classic attack sets alg to none or downgrades RS256 to HS256 so the public key gets used as an HMAC secret. Configure the expected algorithm; never infer it from the token.

Treating an ID token as an access token. ID tokens are minted for a client to learn who the user is, and their aud is that client — not your API. Reject them.

No revocation story. Self-contained tokens stay valid until they expire, which is the tradeoff for stateless validation. Keep access tokens short-lived (minutes, not days) so revoking at the identity provider takes effect quickly. Token expiry best practices covers the tradeoffs.

Authenticating but not rate limiting. A valid token is not a promise of good behavior. Pair auth with rate limiting so one compromised credential cannot exhaust your capacity.

Leaving one route unprotected. This is the one that actually causes incidents. Because policies attach per route, a new route ships with no auth unless someone adds it — so make “which policies are on this route?” part of code review, and keep route configuration in version control where a diff makes the omission visible.

Wrapping up

Gateway-enforced auth is less about picking the strongest scheme than about having exactly one place where the decision is made. Get the boring parts right — check signature, issuer, audience, and expiry; keep tokens short-lived; enforce scopes and claims as explicit steps; never validate the same token twice in two places — and the interesting attacks mostly stop having a surface to work with.

In Zuplo, that shape is a few policies in policies.json attached to the routes that need them, with a TypeScript escape hatch for the decisions configuration cannot express. Both live in version control, which means your auth posture is reviewable in a pull request rather than discoverable in an incident.

The authentication overview has the full policy list if you want to see what is available before wiring anything up.

Frequently asked questions

Common questions, answered.

Try Zuplo free

Try the platform behind this guide

Zuplo is a developer-first API gateway. Deploy your first API in minutes — no credit card required.

  • 100K requests/mo free
  • GitOps deploys
  • 300+ edge locations

Try Zuplo free — 100K requests/mo

Start free