---
title: "Securing APIs at the Gateway: OAuth 2.0 Client Credentials and JWT Validation"
description: "Learn how to enforce OAuth 2.0 client credentials and validate JWTs at the API gateway — issuer, audience, signature, scopes, and claims-based authorization."
canonicalUrl: "https://zuplo.com/learning-center/oauth2-and-jwt-at-the-gateway"
pageType: "learning-center"
authors: "nate"
tags: "API Authentication"
image: "https://zuplo.com/og?text=OAuth%202.0%20and%20JWT%20Validation%20at%20the%20Gateway"
---
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](https://zuplo.com), but the validation rules apply to any gateway.

- [Auth models at the gateway: API keys vs. OAuth 2.0 vs. JWT](#auth-models-at-the-gateway-api-keys-vs-oauth-20-vs-jwt)
- [The OAuth 2.0 client credentials grant for machine-to-machine APIs](#the-oauth-20-client-credentials-grant-for-machine-to-machine-apis)
- [Validating JWTs at the edge](#validating-jwts-at-the-edge)
- [Enforcing scopes and claims after authentication](#enforcing-scopes-and-claims-after-authentication)
- [Integrating an external identity provider](#integrating-an-external-identity-provider)
- [Custom authorization logic in TypeScript](#custom-authorization-logic-in-typescript)
- [Common mistakes](#common-mistakes)
- [Wrapping up](#wrapping-up)
- [Related reading](#related-reading)

## 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](/learning-center/how-to-implement-api-key-authentication)
for the operational side, and
[API key rotation and lifecycle management](/learning-center/api-key-rotation-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](/learning-center/securing-your-api-with-oauth).

### 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](https://zuplo.com/docs/policies/curity-phantom-token-inbound)
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](/learning-center/openid-vs-oauth-vs-oidc) untangles them. And
for the difference between proving *who* a caller is and deciding *what* they may
do, see
[authentication vs. authorization](/learning-center/api-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:

```bash
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:

```json
{
  "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](#common-mistakes) below.

The client then presents it on every API call:

```bash
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](#common-mistakes) for why it matters.

In Zuplo this is a policy in `policies.json` rather than code. The generic
[JWT Auth policy](https://zuplo.com/docs/policies/open-id-jwt-auth-inbound)
works with any OpenID-compliant provider:

```json
{
  "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](https://zuplo.com/docs/articles/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:

```json
{
  "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`:

```typescript
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](https://zuplo.com/docs/articles/oauth-authentication)
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](https://zuplo.com/docs/policies/jwt-scopes-inbound) requires
that a validated token carry specific scopes:

```json
{
  "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](/learning-center/custom-scopes-in-oauth-guide) for
naming conventions that survive growth.

### Claims-based authorization

When the decision depends on claim *values* rather than capabilities, the
[require user claims policy](https://zuplo.com/docs/policies/require-user-claims-inbound)
evaluates a rule against the authenticated user's claims:

```json
{
  "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:

```json
{
  "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](/learning-center/fine-grained-api-authorization-rbac-authzen-gateway).

## 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](https://zuplo.com/docs/policies/auth0-jwt-auth-inbound),
[Okta](https://zuplo.com/docs/policies/okta-jwt-auth-inbound),
[Clerk](https://zuplo.com/docs/policies/clerk-jwt-auth-inbound),
[AWS Cognito](https://zuplo.com/docs/policies/cognito-jwt-auth-inbound),
[Firebase](https://zuplo.com/docs/policies/firebase-jwt-inbound),
[Supabase](https://zuplo.com/docs/policies/supabase-jwt-auth-inbound), and
[PropelAuth](https://zuplo.com/docs/policies/propel-auth-jwt-inbound). 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](https://zuplo.com/docs/concepts/authentication) covers
[Basic Auth](https://zuplo.com/docs/policies/basic-auth-inbound),
[mTLS](https://zuplo.com/docs/articles/securing-the-gateway-with-client-mtls),
and [LDAP](https://zuplo.com/docs/policies/ldap-auth-inbound) (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](https://zuplo.com/docs/articles/multiple-auth-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:

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

```typescript
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](/learning-center/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](/learning-center/api-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](https://zuplo.com/docs/concepts/authentication) has
the full policy list if you want to see what is available before wiring anything
up.

## Related reading

- [Authentication vs. authorization](/learning-center/api-authentication-vs-authorization)
  — the distinction that governs where each check belongs
- [Guide to JWT API authentication](/learning-center/jwt-api-authentication) —
  token structure and signing algorithms in depth
- [Top 7 API authentication methods compared](/learning-center/top-7-api-authentication-methods-compared)
  — the wider landscape
- [API security best practices](/learning-center/api-security-best-practices) —
  what to layer on top of auth