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
- The OAuth 2.0 client credentials grant for machine-to-machine APIs
- Validating JWTs at the edge
- Enforcing scopes and claims after authentication
- Integrating an external identity provider
- Custom authorization logic in TypeScript
- Common mistakes
- Wrapping up
- 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 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:
The response contains the access token:
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:
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:
- Signature — verified against the issuer’s published public key, fetched from its JWKS endpoint.
- Issuer (
iss) — matches the authorization server you actually trust. - Audience (
aud) — names your API, so a token for another service is rejected. - Expiry (
exp) — andnbfif present. - Algorithm — the token’s own
algheader 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:
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.jwkUrlorsecret— one of the two is required. UsejwkUrlfor asymmetric signing (RS256/ES256), where the gateway needs only the public key.secretis for symmetric signing, which means the gateway holds the same key that mints tokens.allowUnauthenticatedRequests— defaults tofalse, so failed authentication returns 401 automatically. Set it totrueonly 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:
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:
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:
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:
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:
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:
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:
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.
Related reading
- Authentication vs. authorization — the distinction that governs where each check belongs
- Guide to JWT API authentication — token structure and signing algorithms in depth
- Top 7 API authentication methods compared — the wider landscape
- API security best practices — what to layer on top of auth