1. The Token That Had No Expiry
In February 2025 I was doing an integration review for a B2B supplier — around 900 trade accounts, Magento 2 backend, a REST API that fed three partner systems and a mobile app. Routine work: read the gateway config, read the auth code, poke at it.
Their partner authentication was a bearer token. One token per partner, issued once, sent in an Authorization header. I asked when the tokens expired. The answer was that they did not — the tokens were "long-lived by design", which is a phrase that means nobody built rotation.
Then I looked at what a partner token could do. It authenticated. It did not scope. Every partner token was accepted by every endpoint, including /api/v1/customers, which returned the full customer list with names, email addresses, phone numbers and trade credit limits, paginated at 200 a page, unauthenticated by anything except the presence of any valid token.
One of those partners was a logistics provider whose integration needed exactly two endpoints: create a shipment, update a tracking status. Their token could read the entire customer database. And the token was in a Postman collection that had been shared, at some point, with a contractor.
I want to be precise about what went wrong there, because the reflex diagnosis is "they should have used OAuth" and that is not the lesson. They had authentication and it worked correctly. Every request was checked. What they did not have was authorisation — any statement of what a given caller was permitted to do — and the gateway, which was the one place that could have expressed it, was configured as a router with a token check bolted on.
That distinction is the whole subject of this article. Not whether requests are authenticated, but what happens after they are: how a token is validated properly, what a scope can and cannot decide, how partner identity works when a bearer token is not good enough, how per-client rate limiting stops one integration taking down your storefront, and how schema validation keeps malformed and malicious payloads out of your services.
What I am deliberately not covering is traffic filtering — signature-based attack detection, OWASP rule sets, bot management, injection payload matching. That belongs in front of or alongside the gateway and it is a different discipline with different failure modes; I have written it up in the WAF implementation guide and there is no point repeating it. A WAF asks "is this request malicious". A gateway asks "who is this and are they allowed". Both are necessary and neither substitutes for the other.
2. What Belongs at the Gateway and What Does Not
The gateway is a policy enforcement point. It is the one place every request passes through, which makes it the right place for decisions that must be consistent, and the wrong place for decisions that need business context.
Put at the gateway: authentication — proving who the caller is; coarse authorisation — is this caller allowed to call this operation at all; rate limiting and quota; request shape validation; TLS termination and mTLS; audit logging of who called what; and consistent error semantics so that every service does not invent its own 401 body.
Keep out of the gateway: anything that needs to know about your data. Whether customer 4471 belongs to trade account 88, whether this order is in a state that permits cancellation, whether this price is visible to this customer group. The gateway does not know and should not be taught, because teaching it means replicating your domain model into a proxy configuration where it will drift.
The line I use: the gateway decides whether a request may reach a service. The service decides whether the specific objects involved may be touched by this caller. Get that split wrong in either direction and you get one of two well-known failure modes — a gateway full of business logic that nobody can safely change, or a service that trusts the gateway and gets breached the moment anything reaches it another way.
Which brings up the assumption worth killing early: services must not trust that traffic arrived through the gateway. A service listening on a port a gateway can reach is a service any compromised pod, any misconfigured security group, and any future colleague with a port-forward can reach. Every service authenticates its callers independently, even when the gateway already did. That is more work and it is the difference between a perimeter and a defence, and it is the practical content of what people mean by zero trust — a topic with its own set of identity plumbing that I have covered in the zero trust article.
3. Three Kinds of Caller, Three Different Answers
Most gateway designs go wrong by trying to authenticate everything the same way. There are three fundamentally different callers on an ecommerce API and they want different mechanisms.
Your own front end, acting for a logged-in customer. The identity is a person. The credential is short-lived, obtained by a login flow, and carried by a browser or app. The critical property is that the token must be narrowly scoped, because it lives in an environment you do not control and will eventually leak — into a screenshot, a shared device, an XSS payload, a browser extension.
A partner system, acting as itself. The identity is an organisation. There is no user. Machine-to-machine, server to server, from a known network with a known operator. This is where mutual TLS earns its place, and where long-lived credentials are least defensible because the operational maturity to rotate them exists on both sides.
An internal service, acting for itself or on behalf of a user. The identity is a workload. Credentials should be issued automatically by the platform and rotated on a timescale of hours, and the interesting problem is delegation — the order service calling the pricing service on behalf of a customer needs to carry both identities, or you get confused-deputy bugs where the service's own privileges get used for a user's request.
Conflating the first two is the most common error I see, and it is exactly the supplier's mistake above: a partner integration authenticated with the same mechanism as a customer session, which meant the scoping model had to serve both and ended up serving neither.
4. Validating a JWT Properly
Most gateways validate JWTs. Most of them do it approximately.
The checks that must all happen, and the failure mode when each is skipped.
Signature, against a key you chose. The classic vulnerability is trusting the token's own alg header. A token claiming "alg": "none" is unsigned and old libraries accepted it. Worse and more current: a token claiming HS256 against a verifier expecting RS256, where a naive implementation uses the public RSA key as an HMAC secret — and the public key is public. Pin the accepted algorithms in your verifier configuration and reject anything else before you look at the signature.
Issuer. A perfectly valid token from a different identity provider is still a valid signature. Check iss against an allowlist.
Audience. The reason aud exists: a token minted for your analytics API should not be accepted by your orders API. Without an audience check, any service in your estate can replay a token it received to any other service. This is the check most often missing in my experience, and it converts a low-privilege service compromise into a full one.
Expiry, with a small and explicit clock skew allowance. Sixty seconds, not five minutes. And nbf if present.
Key rotation via JWKS, with sane caching. Fetch the issuer's key set, cache it, and re-fetch on an unknown kid — but rate-limit that re-fetch, or an attacker sending tokens with random kid values turns your gateway into a denial-of-service amplifier against your identity provider.
import { createRemoteJWKSet, jwtVerify, errors } from 'jose';
// The JWKS client caches keys and re-fetches on an unknown kid. cooldownDuration
// is the important knob: without it, tokens carrying random kid values force a
// fetch per request and you have DDoSed your own IdP.
const JWKS = createRemoteJWKSet(new URL(process.env.ISSUER + '/.well-known/jwks.json'), {
cacheMaxAge: 10 * 60 * 1000,
cooldownDuration: 30 * 1000,
timeoutDuration: 3000,
});
const VERIFY_OPTIONS = {
issuer: process.env.ISSUER, // reject tokens from other IdPs
audience: 'api://orders', // reject tokens minted for another service
algorithms: ['RS256', 'ES256'], // pinned here, NOT read from the token
clockTolerance: 60, // seconds; small and deliberate
maxTokenAge: '30m', // belt and braces on top of exp
};
export async function authenticate(req) {
const header = req.headers.authorization ?? '';
if (!header.startsWith('Bearer ')) {
throw new AuthError(401, 'invalid_request', 'missing bearer token');
}
const token = header.slice(7);
let claims;
try {
({ payload: claims } = await jwtVerify(token, JWKS, VERIFY_OPTIONS));
} catch (e) {
// Distinguish expired from invalid: the client can act on the first and
// must not learn anything from the second.
if (e instanceof errors.JWTExpired) {
throw new AuthError(401, 'invalid_token', 'token expired');
}
throw new AuthError(401, 'invalid_token', 'token rejected');
}
// A revocation check for the tokens that matter. Keep the list small and
// short-lived — it exists because exp alone cannot handle a stolen token.
if (claims.jti && await revoked(claims.jti)) {
throw new AuthError(401, 'invalid_token', 'token revoked');
}
return {
subject: claims.sub,
clientId: claims.client_id ?? claims.azp,
scopes: String(claims.scope ?? '').split(' ').filter(Boolean),
tenant: claims.tenant_id,
expiresAt: claims.exp,
};
}
Two things worth arguing about in there.
The revocation check is a database or cache lookup on every authenticated request, which people resist on latency grounds. My position: a JWT with a 30-minute lifetime and no revocation path means a stolen token is valid for up to 30 minutes and there is nothing you can do about it during an incident. A Redis lookup on a small deny-list costs well under a millisecond. Take the millisecond. If you genuinely cannot, shorten the token lifetime to five minutes and accept the refresh traffic.
And token lifetime generally. I have settled on 15 minutes for customer-facing access tokens with a refresh token that rotates on use, and one hour for machine-to-machine where the client can handle acquisition cleanly. Anything longer is a decision to have no revocation story, and it should be recorded as such rather than drifting into existence.
Opaque tokens and introspection
The alternative to a self-contained JWT is an opaque reference the gateway exchanges for claims at the authorisation server, via RFC 7662 introspection.
The trade is clean. JWTs validate locally — fast, no dependency, and revocation is awkward. Opaque tokens require a call — slower, a hard dependency on the auth server's availability, and revocation is immediate and free because the auth server simply stops saying yes.
What I actually recommend, and it is not a compromise so much as picking the right tool per audience: opaque tokens for anything a browser holds, because those are the tokens that leak and immediate revocation matters most; JWTs for service-to-service, where the caller is inside your trust boundary and the latency of introspection on every hop compounds badly. Cache introspection results for a short window — 30 to 60 seconds — which recovers most of the performance without meaningfully weakening revocation.
import time, httpx, hashlib
class Introspector:
"""RFC 7662 introspection with a short cache and a fail-closed breaker."""
def __init__(self, url, client_id, client_secret, ttl=45):
self.url, self.ttl = url, ttl
self.auth = (client_id, client_secret)
self._cache = {}
self._breaker_open_until = 0.0
async def check(self, token: str) -> dict | None:
# Never key a cache on the raw token; a cache dump then leaks credentials.
key = hashlib.sha256(token.encode()).hexdigest()
hit = self._cache.get(key)
if hit and hit[1] > time.time():
return hit[0]
if time.time() < self._breaker_open_until:
return None # auth server is down: deny, do not assume
try:
async with httpx.AsyncClient(timeout=2.0) as c:
r = await c.post(self.url, data={"token": token}, auth=self.auth)
r.raise_for_status()
data = r.json()
except Exception:
self._breaker_open_until = time.time() + 5
return None
if not data.get("active"):
# Cache negatives briefly too, or a revoked token becomes a
# free introspection load generator.
self._cache[key] = (None, time.time() + 5)
return None
# Never cache past the token's own expiry.
ttl = min(self.ttl, max(0, data.get("exp", 0) - time.time()))
self._cache[key] = (data, time.time() + ttl)
return data
The breaker failing closed is a deliberate and occasionally unpopular choice. If your auth server is unavailable, your API stops accepting authenticated requests. The alternative — accept requests you cannot verify — means an auth server outage becomes an authorisation bypass, which is a much worse incident than downtime. Make the auth server highly available rather than making the gateway optimistic.
5. Scopes Are Coarse, and That Is the Point
A scope answers "may this client call this kind of operation". It cannot answer "may this client see this particular record", and every design that tries to make it do so ends up with a scope per customer.
The mental model that keeps this straight: a scope is about the capability, the service checks the instance. orders:read gets you to the orders endpoint. Whether order 88213 is yours is a question about data, and only the order service can answer it.
Designing the taxonomy
Scopes go wrong in two directions. Too coarse — read and write — and every client gets everything. Too fine — orders:read:line_items:price — and nobody can reason about what a client can do, so everyone requests the union of everything and you are back to coarse with extra steps.
The shape that has worked for me is resource:action, with a small number of resources matching the top-level nouns of the API and three or four actions. Then a separate qualifier for the data sensitivity tier, because "read a product" and "read a customer" are not the same risk and should not be grantable by the same request.
| Scope | Grants | Typical holder | Sensitivity |
|---|---|---|---|
catalog:read | Products, prices, stock levels | Any partner, storefront | Low |
orders:create | Place an order | Storefront, marketplace connector | Medium |
orders:read | Order header and lines | ERP, customer session | Medium |
shipments:write | Create shipment, set tracking | Logistics partner only | Medium |
customers:read | Names, addresses, contact | CRM sync only | High — personal data |
customers:read:pii | Full contact detail, credit terms | Nobody by default | High — separate grant |
payments:read | Transaction status, last four | Finance reconciliation | High — audited |
admin:* | Does not exist | — | Never issue a wildcard |
The logistics partner from the opening gets shipments:write and orders:read. That is it. Under that model, the same leaked token retrieves shipment-relevant order data and nothing else — still a problem, and a vastly smaller one than the entire customer database.
Enforce scopes declaratively next to the route definition, not in each handler, so that adding an endpoint without declaring its scope is a configuration error rather than a silent grant.
# Route policy. Vendor-neutral shape; every gateway has an equivalent.
# The default is deny: an operation with no policy entry is not routable.
default_policy: deny
routes:
- match: { method: GET, path: /v1/products/** }
scopes: [catalog:read]
auth: [jwt, mtls]
rate_limit: standard
- match: { method: POST, path: /v1/shipments }
scopes: [shipments:write]
auth: [mtls] # partners only; no browser-held token
rate_limit: partner
schema: schemas/shipment-create.v1.json
- match: { method: GET, path: /v1/customers/** }
scopes: [customers:read]
auth: [jwt]
rate_limit: sensitive
audit: full # log caller, target id, and fields returned
response_filter: filters/customer-public.json
- match: { method: GET, path: /v1/customers/*/credit }
scopes: [customers:read:pii, finance:read] # BOTH required, not either
auth: [mtls]
rate_limit: sensitive
audit: full
Two details there that matter. default_policy: deny means a new endpoint deployed without a policy entry returns 404 at the gateway rather than being exposed with no checks — the single most valuable line in the file. And the last route requires both scopes rather than either, which is how you express "finance systems only" without inventing a role system inside your scope strings.
6. Object-Level Authorisation, and Why It Cannot Live Here
The most common serious API vulnerability in ecommerce is not injection and it is not a broken token check. It is broken object-level authorisation: GET /v1/orders/88213 returns order 88213 to any caller holding orders:read, regardless of whose order it is.
It is easy to write, invisible in testing because your test fixtures all belong to the test user, and trivially discoverable by anyone who increments an integer.
The gateway cannot fix this. It does not know who owns order 88213 and giving it that knowledge means giving a proxy a database connection, which is a worse idea than the bug. What the gateway can do is make the failure detectable and make the correct pattern easy.
Pass identity downstream in a form the service must use. Strip any client-supplied identity headers at the edge — unconditionally, including ones you do not recognise — and inject verified ones. A service that reads X-Customer-Id from an inbound request without knowing whether the gateway set it or the client did is one curl away from an incident.
location /v1/ {
# Remove ANY inbound identity headers before we add our own. A client that
# sends X-Customer-Id must not be able to influence what the service sees.
proxy_set_header X-Customer-Id "";
proxy_set_header X-Client-Id "";
proxy_set_header X-Scopes "";
proxy_set_header X-Auth-Method "";
auth_request /_authn; # validates and sets the vars
auth_request_set $customer_id $upstream_http_x_verified_customer;
auth_request_set $client_id $upstream_http_x_verified_client;
auth_request_set $scopes $upstream_http_x_verified_scopes;
proxy_set_header X-Customer-Id $customer_id;
proxy_set_header X-Client-Id $client_id;
proxy_set_header X-Scopes $scopes;
proxy_set_header X-Auth-Method $auth_method;
proxy_set_header X-Request-Id $request_id; # correlate across services
proxy_pass http://orders_upstream;
}
Then make the service query with the identity, not filter after. The pattern that fails is fetching by id and then checking ownership, because someone eventually writes a code path that skips the check. The pattern that holds is making ownership part of the query, so there is no version of the code that returns the wrong row.
-- Wrong shape: fetch, then check in application code.
SELECT * FROM sales_order WHERE entity_id = :order_id;
-- Right shape: ownership is a predicate, so a missed check cannot leak.
-- A caller asking for someone else's order gets zero rows, which the handler
-- turns into 404 — not 403, which would confirm the order exists.
SELECT o.*
FROM sales_order o
WHERE o.entity_id = :order_id
AND (
(:auth_type = 'customer' AND o.customer_id = :customer_id)
OR (:auth_type = 'partner' AND o.trade_account_id = ANY(:partner_accounts))
);
The 404-not-403 detail is worth internalising. Returning 403 for an object that exists but is not yours is an existence oracle: an attacker walks the id space and learns your order volume, or worse, learns which ids are real before trying harder. Return 404 for both cases and log the difference internally.
Use non-sequential public identifiers too — UUIDs or a ULID — so that walking the id space is not free. That is defence in depth, not a fix; an unguessable id is still not an authorisation check.
7. mTLS for Partners
For machine-to-machine traffic between organisations, mutual TLS is the mechanism I would reach for first, and it is underused because it has a reputation for operational pain that is mostly earned by bad certificate management rather than by the protocol.
What it buys you. The credential is a private key that never travels — unlike a bearer token, which is transmitted on every request and is therefore replayable by anyone who obtains it from a log, a proxy, a screenshot, or a Postman collection. Client identity is established during the handshake, before your application sees a byte. And it composes: mTLS proves the machine, a token in the request can still carry the acting user, and you can require both.
The design decisions that matter more than the config.
Run a private CA for client certificates. Do not accept certificates from public CAs — that would mean anyone with a valid certificate from any trusted CA is a candidate. Issue from a CA that exists solely to identify your partners, and pin that CA at the gateway.
Put the identity in the certificate and map it explicitly. The common name or a SAN URI carries the partner identifier, and the gateway maps that to a client record with scopes. Do not derive authorisation from the certificate's fields directly; map through a table you control, so revoking a partner is a row change rather than a CRL propagation race.
Short lifetimes and automated renewal, or it will not work. A three-year client certificate is a three-year credential nobody rotates and an outage the day it expires. 90 days with automated renewal is the modern default, and the automation is the hard part — but a partner who cannot automate renewal is a partner who will also not rotate a leaked token.
Alert on approaching expiry loudly and early. 30, 14, and 7 days, to both parties. The most common mTLS incident by a wide margin is an expired client certificate on a Sunday.
server {
listen 443 ssl;
server_name partner-api.example.com;
ssl_certificate /etc/ssl/api.crt;
ssl_certificate_key /etc/ssl/api.key;
ssl_protocols TLSv1.3 TLSv1.2;
# Our own partner CA only. A cert from any public CA must not be accepted.
ssl_client_certificate /etc/ssl/partner-ca-bundle.pem;
ssl_verify_client on;
ssl_verify_depth 2;
# Revocation actually checked, not merely available.
ssl_crl /etc/ssl/partner-crl.pem;
location /v1/ {
# The verified subject DN is the partner identity. It is set by the TLS
# stack and cannot be forged by the client, unlike any header.
proxy_set_header X-Client-DN $ssl_client_s_dn;
proxy_set_header X-Client-Serial $ssl_client_serial;
proxy_set_header X-Client-Verify $ssl_client_verify;
# Certificate expiry is an operational signal, not just a TLS outcome.
proxy_set_header X-Client-Cert-Expiry $ssl_client_v_end;
proxy_pass http://partner_upstream;
}
}
A caution that has bitten me. If you terminate TLS at a CDN or a cloud load balancer, client certificate information may not survive to your origin, or may arrive in a vendor-specific header that a client could also send. Verify what actually reaches your application, and if the identity arrives in a header, strip that header from client-supplied input at the very first hop. I found exactly this on a review once: mTLS was configured, the origin trusted a header the load balancer set, and the load balancer did not strip the same header when a client sent it. The mTLS was decorative.
8. Rate Limiting Per Client, Not Per IP
IP-based rate limiting is the default in most gateway configurations and it is close to useless for authenticated APIs. Partners come from a handful of NAT addresses, so one busy partner's limit is consumed by another's traffic. Mobile clients share carrier gateways. And the caller you most want to limit is the one you can identify precisely, because they authenticated.
Key limits on the authenticated client identity — the OAuth client id, the certificate serial, the partner record — and fall back to IP only for unauthenticated endpoints.
Then run limits at more than one granularity, because they solve different problems. A per-second burst limit protects your infrastructure from a runaway loop. A per-hour or per-day quota expresses a commercial agreement. A per-endpoint limit protects the expensive operations specifically — a partner making 200 requests a second to a cached product endpoint is fine, and 200 a second to order search is not.
-- Redis Lua: sliding-window counter, atomic, one round trip.
-- Fixed windows allow 2x the intended rate across a boundary; a sliding window
-- weights the previous window by how far into the current one we are.
local key_prev, key_curr = KEYS[1], KEYS[2]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2]) -- seconds
local elapsed = tonumber(ARGV[3]) -- seconds into the current window
local cost = tonumber(ARGV[4]) -- not every request costs 1
local prev = tonumber(redis.call('GET', key_prev) or '0')
local curr = tonumber(redis.call('GET', key_curr) or '0')
local weight = (window - elapsed) / window
local estimated = prev * weight + curr
if estimated + cost > limit then
-- Tell the caller when to come back rather than making them guess.
local retry = math.ceil((estimated + cost - limit) / (limit / window))
return {0, math.floor(limit - estimated), retry}
end
redis.call('INCRBY', key_curr, cost)
redis.call('EXPIRE', key_curr, window * 2)
return {1, math.floor(limit - estimated - cost), 0}
The cost parameter is the part people leave out and then regret. Not all requests are equal: a product lookup by SKU is cheap, a filtered order search across two years is expensive, a bulk export is enormously expensive. Assign a cost per operation and limit on the total, so one client cannot exhaust your database while remaining comfortably within a request-count limit.
Return the standard headers so clients can behave. This matters more than it sounds — a partner who can see their remaining quota will back off; a partner who only ever sees a 429 will retry immediately and turn your rate limit into a load generator.
HTTP/1.1 429 Too Many Requests
RateLimit-Limit: 1000
RateLimit-Remaining: 0
RateLimit-Reset: 47
Retry-After: 47
Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/rate-limit",
"title": "Rate limit exceeded",
"status": 429,
"detail": "1000 requests per hour for client logistics-partner-3. Resets in 47s.",
"policy": "partner-standard",
"documentation": "https://developers.example.com/limits"
}
One operational note that has saved a client's Black Friday. Have a documented, fast path to raise a specific partner's limit without a deploy — a configuration value in a store the gateway reads at runtime. During peak trading somebody always needs more headroom, and the choice between "deploy a config change under freeze" and "let the integration fail" is a choice you should not have to make at 11pm.
The other side of rate limiting is what happens when a limit is hit repeatedly. A client stuck in a retry loop against a 429 is a client you should shed entirely — a short circuit-break per client after, say, 100 consecutive rejections, which costs them nothing they were getting anyway and costs you a connection instead of a request.
9. Validate the Request Against a Schema
Schema validation at the gateway is the cheapest security control on this page and the most frequently skipped, because it feels like duplicating what the service already does.
What it actually buys. Malformed payloads never reach your services, so a parser bug in a downstream library is unreachable from outside. Unknown fields are rejected rather than silently ignored, which closes mass-assignment — the bug where a client posts {"total": 0.01} to an order endpoint and a permissive ORM writes it. Size and depth limits stop resource exhaustion before it consumes a worker. And it gives clients consistent, specific error messages, which reduces integration support load dramatically.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "shipment-create.v1",
"type": "object",
"additionalProperties": false,
"required": ["order_reference", "carrier", "packages"],
"properties": {
"order_reference": {
"type": "string",
"pattern": "^ORD-[0-9]{8}$"
},
"carrier": {
"type": "string",
"enum": ["dpd", "royalmail", "dhl", "ups"]
},
"tracking_number": {
"type": "string",
"maxLength": 64,
"pattern": "^[A-Za-z0-9-]+$"
},
"packages": {
"type": "array",
"minItems": 1,
"maxItems": 50,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["weight_grams"],
"properties": {
"weight_grams": { "type": "integer", "minimum": 1, "maximum": 40000 },
"dimensions_mm": {
"type": "array",
"items": { "type": "integer", "minimum": 1, "maximum": 3000 },
"minItems": 3,
"maxItems": 3
}
}
}
},
"dispatched_at": { "type": "string", "format": "date-time" }
}
}
additionalProperties: false is the line doing the security work. It is also the line that breaks partners who send extra fields, so version your schemas and give partners a grace period with warning headers before you enforce. I usually run new schemas in report-only mode for two weeks, logging what would have been rejected, and it always surfaces at least one integration sending something unexpected.
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
const ajv = new Ajv({ allErrors: true, strict: true, removeAdditional: false });
addFormats(ajv);
const validators = new Map(); // route key -> compiled validator
export function validateRequest({ reportOnly = false } = {}) {
return async (req, res, next) => {
const schemaId = req.routePolicy?.schema;
if (!schemaId) return next();
// Cheap checks before the expensive one: reject oversized bodies without
// parsing them, because parsing is where the resource cost lives.
const declared = Number(req.headers['content-length'] ?? 0);
if (declared > 256 * 1024) {
return problem(res, 413, 'Payload too large', `${declared} bytes`);
}
const validate = validators.get(schemaId) ?? compile(schemaId);
if (validate(req.body)) return next();
const errors = validate.errors.map(e => ({
field: e.instancePath || '(root)',
rule: e.keyword,
detail: e.message,
}));
if (reportOnly) {
// Grace period: log what we would have rejected, let it through, and
// tell the client so their team sees it before enforcement day.
req.log.warn({ schemaId, errors, client: req.auth.clientId },
'schema violation (report-only)');
res.setHeader('Deprecation-Warning', `payload will be rejected from ${ENFORCE_DATE}`);
return next();
}
return problem(res, 400, 'Request body failed validation', errors);
};
}
Validating the response too
Less common, and it catches a category of bug nothing else does: your own service leaking fields it should not.
An ORM that serialises a whole entity will happily include password_hash, internal_notes, cost_price, or credit_limit because someone added a column and no one updated a serialiser. A response filter at the gateway, defined as an allowlist per route, means the leak is caught at the boundary regardless of what the service did.
Cost price is the one I would specifically call out for ecommerce. I have seen it in a product API response twice, both times on a public endpoint, both times because the API returned the full product entity and nobody read the JSON carefully. A competitor with your cost prices knows exactly how far you can go on a match.
Run the filter in log-only mode first and read what it would strip. It is an uncomfortable and educational few days.
10. GraphQL Changes the Shape of the Problem
Everything above assumes REST, where the operation is the URL and route-level policy works. GraphQL has one endpoint and an arbitrarily complex query in the body, so gateway-level authorisation by path is meaningless.
Three controls that are not optional on a public GraphQL endpoint.
Persisted queries, ideally exclusively. Clients register their operations at build time and send a hash at runtime. The server executes only known queries. This eliminates arbitrary query construction entirely, which collapses the depth, complexity and introspection problems into a build-time review. If you control every client, do this and most of the rest of this section becomes unnecessary.
Complexity limits, if you must accept arbitrary queries. Depth alone is insufficient — a shallow query requesting 1,000 products each with 100 reviews is small in depth and enormous in cost. Score the query by multiplying list sizes down the tree and reject above a budget, with the budget varying by client.
Field-level authorisation in the schema. Because the gateway cannot see which fields a query touches without parsing it, the enforcement point moves into the resolvers. Directives declared on the schema are the maintainable form of this.
# Authorisation declared on the schema, enforced in a directive resolver, so
# adding a sensitive field without a rule is visible in code review.
directive @requiresScope(scopes: [String!]!) on FIELD_DEFINITION
directive @cost(complexity: Int! = 1, multipliers: [String!]) on FIELD_DEFINITION
type Customer {
id: ID!
displayName: String!
email: String! @requiresScope(scopes: ["customers:read:pii"])
phone: String @requiresScope(scopes: ["customers:read:pii"])
creditLimit: Money @requiresScope(scopes: ["finance:read"])
orders(first: Int = 20): [Order!]!
@cost(complexity: 5, multipliers: ["first"])
@requiresScope(scopes: ["orders:read"])
}
type Query {
# Bounded by construction: no unbounded list anywhere in the schema.
products(first: Int = 24, after: String): ProductConnection!
@cost(complexity: 2, multipliers: ["first"])
customer(id: ID!): Customer @requiresScope(scopes: ["customers:read"])
}
Also: disable introspection in production, and turn off field suggestions in error messages. The "did you mean creditLimit" hint is a schema disclosure mechanism that survives disabling introspection, and almost nobody switches it off.
The performance dimension of all this — where to cache, how query cost interacts with response times, when a REST facade in front of GraphQL is the right call — I have covered separately in the GraphQL and REST performance article. The security shape and the performance shape push in the same direction more often than not: bounded, known queries are both safer and faster.
11. Webhooks Are an API Pointing the Other Way
Every ecommerce estate receives webhooks — payment notifications, shipping updates, marketplace order pushes — and they are consistently the least defended endpoints on the system, because they cannot use your normal authentication and so they get an exemption.
What a webhook receiver needs. Signature verification using the sender's documented scheme, compared in constant time, with the raw body rather than a re-serialised version — re-serialising changes whitespace and key order and breaks the signature in ways that lead people to disable verification. A timestamp check to prevent replay, five minutes or less. Idempotency, keyed on the sender's event id, because every serious webhook provider will eventually deliver twice and a duplicate payment capture is a real incident. And an allowlist of source addresses where the sender publishes them, as a second factor rather than the primary one.
import hmac, hashlib, time
from fastapi import Request, HTTPException
MAX_SKEW = 300 # seconds
async def verify_webhook(request: Request, secret: bytes) -> dict:
# The RAW body. Parsing and re-serialising changes bytes and breaks HMAC.
raw = await request.body()
sig = request.headers.get("X-Signature", "")
ts = request.headers.get("X-Timestamp", "")
if not ts.isdigit() or abs(time.time() - int(ts)) > MAX_SKEW:
raise HTTPException(400, "stale or missing timestamp")
expected = hmac.new(secret, f"{ts}.".encode() + raw, hashlib.sha256).hexdigest()
# compare_digest, never ==. A byte-by-byte comparison leaks the signature
# through timing given enough attempts.
if not hmac.compare_digest(expected, sig):
raise HTTPException(401, "bad signature")
event = json.loads(raw)
# Idempotency: claim the event id atomically. Second delivery is a no-op.
if not await claim_event(event["id"], ttl=86_400):
return {"status": "duplicate", "id": event["id"]}
return event
And return 200 fast. Acknowledge, enqueue, process asynchronously. A webhook handler that does the work inline will time out under load, the sender will retry, and you will process the same event three times while your queue is empty and your web workers are all blocked.
12. Logging: Who Called What, and What Came Back
The gateway is the only place with a complete view of API access, which makes it the natural audit point and the natural detection point.
What to log on every request: timestamp, request id, authenticated client id and subject, authentication method, route matched, scopes presented, decision and reason, status, latency, and response size. What never to log: the token itself, the request or response body for anything carrying personal data, and any header you have not explicitly allowlisted. A log pipeline that captures Authorization headers is a credential store you did not mean to build.
The detections worth building on top, which are all straightforward once the fields above exist. A client's 403 rate rising — someone is probing or an integration has drifted. A client accessing a resource type it has never touched in 90 days. A single client's request volume rising more than an order of magnitude within an hour. Sequential-looking id access patterns against object endpoints, which is enumeration in progress. And access to high-sensitivity scopes outside the caller's normal hours, which catches the credential-used-from-elsewhere case surprisingly often.
-- Enumeration detector: one client walking an id space.
-- Runs every 15 minutes against the gateway access log.
WITH ordered AS (
SELECT client_id, path_object_id::bigint AS oid, ts,
LAG(path_object_id::bigint) OVER (PARTITION BY client_id ORDER BY ts) AS prev
FROM gateway_access_log
WHERE ts > now() - interval '15 minutes'
AND route = '/v1/orders/{id}'
AND path_object_id ~ '^[0-9]+$'
)
SELECT client_id,
count(*) AS requests,
count(*) FILTER (WHERE oid - prev BETWEEN 1 AND 3) AS near_sequential,
min(oid), max(oid)
FROM ordered
WHERE prev IS NOT NULL
GROUP BY client_id
-- A legitimate client fetches the orders it knows about, in no particular
-- order. Consecutive ids in volume is someone counting.
HAVING count(*) FILTER (WHERE oid - prev BETWEEN 1 AND 3) > 40;
13. The Supplier, Rebuilt
What actually happened after the review that opened this article, over about five months, with numbers.
Starting position. 14 partner integrations, one non-expiring bearer token each, no scopes, no per-client rate limiting, IP allowlisting on four of the fourteen. 61 REST endpoints, of which 9 were undocumented and 3 turned out to be unused since 2022. No request schema validation. Gateway access logs retained for 7 days and not queried by anyone.
What we did, in the order we did it. First, an inventory: every endpoint, every consumer, what each consumer actually called over 90 days of logs. That took nine days and it was the most valuable part of the project — 3 endpoints were deleted outright, and the gap between what partners were permitted to call and what they did call was the scope design, essentially handed to us by the data.
Then default-deny routing with a policy file, deployed in report-only mode for three weeks. It flagged 11 request patterns nobody expected, of which 2 were real integrations using an endpoint they had never mentioned and 1 was a former employee's script still running on a laptop.
Then scopes, issued per-partner against the observed usage, with a 30-day parallel period where scope violations warned rather than blocked. Then OAuth client credentials with one-hour tokens replacing the eternal bearer tokens, partner by partner, over six weeks. Then mTLS for the four highest-value integrations. Then per-client rate limits, then schema validation in report-only, then enforcing.
Results at five months. The logistics partner's credential now carries shipments:write and orders:read, and the customer endpoint is reachable by exactly one client. Median added gateway latency 4.1ms, p95 11ms — the JWT verification is sub-millisecond against a cached JWKS and the revocation lookup dominates. Schema validation rejected 340 malformed requests in the first month, of which 300 were one partner sending an incorrectly formatted date and had been silently failing downstream for over a year. Two credential rotations performed, both under 20 minutes, both without an outage — which was the point.
Three things that went wrong
The report-only period was too short for one partner. A marketplace connector ran a monthly reconciliation job on the 4th, and our three-week report-only window happened to miss it. We enforced, their job failed, and it took two days to notice because their error handling swallowed 403s. Any observation window has to cover the longest cycle in the system, and for ecommerce that is a month at minimum and arguably a year — nobody thinks about the annual VAT export until it breaks.
We broke a partner with additionalProperties: false. An ERP was sending a legacy_ref field that our schema did not know about. Harmless, ignored downstream, and rejected on enforcement day. The fix was to add the field to the schema as an ignored string, and the lesson was to build the schema from observed traffic rather than from the documentation, then tighten.
mTLS certificate renewal was not automated for one partner and expired at 02:00 on a Saturday. We had expiry alerts at 30 and 7 days going to a shared mailbox that nobody read during a holiday period. Alerts now go to a channel with an on-call rotation and to the partner's technical contact directly. This is not a clever failure; it is the most predictable failure in the entire mTLS story and we walked into it anyway.
What I would do differently
I would do the log inventory before proposing any design at all. We spent time debating scope taxonomy in the abstract before we had the usage data, and the data settled every one of those debates in an afternoon. Ninety days of access logs is a specification.
I would also have started token lifetime reduction before the scope work rather than after. Scopes limit the damage of a leaked credential; short lifetimes limit its duration. The second is easier, faster, and independent of any taxonomy discussion, and I sequenced them the wrong way round.
14. Questions That Come Up
"Do we need a gateway if we only have one API?" Not necessarily a product, but you need the function. The checks have to happen somewhere consistent, and a middleware chain in your application can be that place. What you get from a separate gateway is enforcement that survives someone adding a route without thinking, and a policy file that is reviewable independently of application code. Below about three services, middleware is fine and honest.
"API keys or OAuth?" API keys are bearer credentials with no expiry, no scope structure and no standard revocation. They are acceptable for low-sensitivity read-only access — a public catalogue feed — where the worst case is someone reading data you publish anyway. For anything touching orders, customers or money, use OAuth client credentials, because you get expiry, scopes, and rotation for free from every library rather than building three-quarters of OAuth badly.
"How long should tokens live?" Customer access tokens: 15 minutes, with a rotating refresh token. Machine-to-machine: one hour. Anything longer requires a revocation mechanism you have tested, and "we would redeploy with a new secret" is not one. The question I would ask a team is simpler: if you learned right now that a partner's token was public, how long until it stops working? If the answer is "we would have to write something", that is the finding.
"Is mTLS worth the operational cost?" For partner integrations, yes, provided both sides can automate renewal. It removes the entire class of "the credential was in a log, a screenshot, a support ticket, a Postman collection". For customer-facing traffic, no — certificate distribution to browsers and mobile apps is a much bigger problem than the one it solves.
"Won't all this validation slow the API down?" A little, and less than people fear. In the rebuild above, the full chain — token verification against a cached JWKS, revocation lookup, scope check, schema validation — added about 4ms at the median. Compare that to the 40ms your database query takes and the 200ms your third-party tax call takes. If validation is genuinely your bottleneck, you have an unusually fast application and a pleasant problem.
"Should the gateway do caching too?" It can, and be careful. A cache keyed without the caller's identity will serve one customer's data to another, and this is a real breach class rather than a theoretical one. If you cache at the gateway, cache only responses that are genuinely identity-independent — product data, category listings — and make identity part of the key everywhere else, or simply do not cache authenticated responses at the edge.
"What about versioning?" Version in the path, keep old versions running until you have evidence nobody uses them, and get that evidence from your gateway logs rather than from asking partners. Every partner will tell you they have migrated. The logs will tell you which one has not.
"Where does the WAF sit relative to this?" In front, generally at the CDN edge, filtering obviously malicious traffic before it reaches your gateway. They overlap on almost nothing: a WAF has no idea whether the authenticated caller is allowed to read customer 4471, and a gateway has no signature database for SQL injection payloads. Run both, and do not let a vendor tell you either one makes the other redundant.
"How do we roll this out without breaking every partner?" Report-only mode for everything, for a window that covers the longest business cycle in your system. Log what would have been blocked, contact the affected partners individually with their specific request examples, and enforce with a published date. The whole rebuild above went in without an unplanned partner outage except the two I described, and both were window-length problems rather than design problems.
15. Where I'd Start
In this order, because each step gives you the information for the next.
Pull 90 days of access logs and build the matrix: which client called which endpoint, how often. If your logs do not support that query, fixing the logging is step zero and everything else waits. This inventory answers your scope design, finds your unused endpoints, and identifies the integrations nobody remembers approving.
Check the token validation you already have against the list above: algorithm pinned, issuer checked, audience checked, expiry enforced with a small skew, JWKS cached with a cooldown. Missing audience is the one I find most often and the one with the largest blast radius.
Find every credential without an expiry and give it one. Start with the longest-lived. This is independent of any other work here and it is the single change with the best ratio of risk removed to effort spent.
Turn on default-deny routing in report-only mode. Read what it flags for three weeks — or a full month if anything in your business runs monthly. Then enforce.
Design scopes from the observed usage matrix, not from a whiteboard. Issue them per client at the level they actually operate. Grant nothing "just in case".
Add per-client rate limits with a cost weighting on the expensive operations, and return the standard headers so clients can behave well.
Add request schema validation, report-only first, built from real traffic rather than from your documentation.
Then audit one endpoint's object-level authorisation by hand: authenticate as customer A, request customer B's order by id, and see what comes back. If it returns the order, you have found the bug that matters more than anything else on this page, and it is almost certainly not the only instance.
A last thought about where the real risk sits. Nearly every API breach I have looked into came down to the same thing: a credential that was valid, presented correctly, and permitted to do far more than the integration it belonged to ever needed. Not a broken cipher, not a clever bypass. Somebody, under time pressure, issued a token that worked for everything because working out what it actually needed would have taken an afternoon and the launch was on Thursday. The gateway is where you get to make that afternoon cheap — one policy file, one place, reviewable — and the whole argument for putting authorisation there rather than in fourteen services is that it turns a decision people make badly under pressure into a decision they make once, visibly, with the diff in front of them.
Suggested & Related Reading
Explore related engineering guides from Kenneth D'Silva:
-
GraphQL vs. REST API Performance Optimization
Query depth limiting and complexity analysis.
-
Implementing a Web Application Firewall (WAF)
API gateway WAF expression rules.
-
Secure E-Commerce Deployment Checklist
Comprehensive security validation for headless and monolithic architectures.