Authentication at the API Gateway
What belongs at the edge, what must stay in the service, and why the gateway is never the whole answer.
An API gateway is the natural place to put authentication: one implementation, one place to patch, consistent errors. It is also the natural place to put too much authentication and leave the services defenceless.
What the gateway does well
- Token verification — signature, issuer, audience, expiry. Uniform and cached.
- Normalising credentials — API keys, cookies, and OAuth tokens all become one internal identity format.
- Coarse authorization — "this route needs scope
orders:read" is a routing-table fact. - Rate limiting and quotas — needs a global view the individual service does not have.
- Stripping dangerous headers before they reach anything.
What it cannot do
Anything that requires knowing the data. "Is invoice 1043 Alice's?" needs a database lookup the gateway has no business doing. Row-level ownership, field-level permissions and business rules stay in the service, always.
This is the crisp division worth memorising: the gateway authorizes the route; the service authorizes the object.
The failure mode
Gateway-only authentication produces a soft interior. Anything that reaches a service directly — a misconfigured ingress, an internal caller, an SSRF, a debug port — is unauthenticated and unnoticed. Services must verify something of their own. See the previous lesson for the internal-token pattern.
Practical concerns
- Cache the JWKS at the gateway, and cache introspection results for a few seconds if you use opaque tokens.
- Preserve a request id across every hop or you cannot trace an authorization decision later.
- Return uniform errors. A gateway 401 and a service 401 that look different tell an attacker where the boundary is.
- Do not terminate the only copy of the credential. Services that need the user's identity should get a verifiable assertion, not a plain header.
Example
# Kong / Envoy / API Gateway — what belongs where
AT THE GATEWAY IN THE SERVICE
──────────────────────────────── ──────────────────────────────────
verify token signature verify the internal token
check iss / aud / exp check the object belongs to the user
map credential → identity apply field-level permissions
route-level scope check business rules and workflow state
rate limit / quota per-object rate limits, if any
strip x-internal-* from clients never trust an unverified header
request id generation propagate the request id
# The dividing line:
# gateway → can this CALLER reach this ROUTE?
# service → can this USER touch this OBJECT?When to use it
- A gateway rejects tokens with the wrong audience for every route at once, removing a check each of forty services would otherwise implement inconsistently.
- A service keeps its ownership check despite gateway authentication, and that check is what stops an IDOR when a new route is added.
- A misconfigured internal load balancer exposes a service directly, and the internal-token requirement means the exposure is not a breach.
More examples
Envoy: verify JWT at the edge, per-route scope
request_headers_to_remove runs before the service sees anything, which is the gateway half of the never-trust-a-header rule.
# envoy.yaml — JWT authentication filter
http_filters:
- name: envoy.filters.http.jwt_authn
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.JwtAuthentication
providers:
main:
issuer: https://auth.dfg.com
audiences: [ "https://dfg.com/api" ]
remote_jwks:
http_uri:
uri: https://auth.dfg.com/.well-known/jwks.json
cluster: auth_cluster
timeout: 5s
cache_duration: { seconds: 600 } # do not fetch per request
# Hand the verified claims to the service as a structured header —
# NOT as a plain "x-user-id" the client could have forged.
payload_in_metadata: jwt_payload
forward: true # the service can re-verify
rules:
- match: { prefix: "/api/public" } # no requirement
- match: { prefix: "/api" }
requires: { provider_name: main }
# RBAC on the ROUTE — coarse, and correctly placed here.
- name: envoy.filters.http.rbac
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.rbac.v3.RBAC
rules:
action: ALLOW
policies:
admin-routes:
permissions:
- url_path: { path: { prefix: "/api/admin" } }
principals:
- metadata:
filter: envoy.filters.http.jwt_authn
path: [{ key: jwt_payload }, { key: scope }]
value: { string_match: { contains: "admin:all" } }
# Strip client-supplied internal headers before routing.
request_headers_to_remove:
- x-internal-token
- x-user-id
- x-tenant-idUniform errors across the boundary
Exposing only a request id gives support everything they need while telling an attacker nothing about which layer refused them or why.
// Gateway and services must produce IDENTICAL error shapes, or the difference
// maps out your architecture for anyone probing it.
export function authError(res, { status, error, description, scheme = 'Bearer' }) {
const params = [`realm="api"`, `error="${error}"`];
if (description) params.push(`error_description="${description}"`);
res.set('WWW-Authenticate', `${scheme} ${params.join(', ')}`);
res.set('Cache-Control', 'no-store');
return res.status(status).json({
error,
// Never include: which layer rejected it, internal hostnames, stack traces,
// or whether the user/tenant/object exists.
message: PUBLIC_MESSAGES[error] ?? 'Request could not be authenticated.',
requestId: res.locals.requestId, // the ONLY internal detail exposed
});
}
const PUBLIC_MESSAGES = {
authentication_required: 'Authentication is required.',
invalid_token: 'The credential is not valid.',
token_expired: 'The credential has expired.',
insufficient_scope: 'This credential does not permit that action.',
};
// ❌ What NOT to emit — each line is a free hint:
// "gateway: jwt audience mismatch (expected https://dfg.com/api)"
// "orders-service: internal token missing — did the gateway forward it?"
// "user 42 not found in tenant acme"
// The requestId is how support correlates a user report with the real reason,
// which is logged internally in full.Deciding what to enforce where, concretely
Walking an interviewer through one endpoint like this demonstrates the division better than any abstract description of gateway responsibilities.
// A worked example on one endpoint: DELETE /api/tenants/:t/invoices/:id
// ---- GATEWAY: route-level only. It knows nothing about invoices. ----
// route: DELETE /api/tenants/*/invoices/*
// requires: valid token, iss=auth.dfg.com, aud=https://dfg.com/api
// requires: scope contains 'invoices:delete'
// rate limit: 100/min per client
// strips: x-internal-*, x-user-*, x-tenant-*
// mints: x-internal-token { sub, scope, tenant }
// ---- SERVICE: everything that needs data ----
app.delete('/api/tenants/:tenantId/invoices/:id', internalAuth, async (req, res) => {
// 1. Tenant isolation — the token's tenant must match the path.
// The gateway CANNOT do this: it would have to parse your URL semantics.
if (req.user.tenant !== req.params.tenantId) {
return res.status(404).json({ error: 'not_found' }); // not 403 — no leak
}
// 2. Ownership + tenant, inside the query.
const invoice = await db.invoices.findOne({
id: req.params.id,
tenantId: req.user.tenant,
});
if (!invoice) return res.status(404).json({ error: 'not_found' });
// 3. Business rule — nothing outside this service could know it.
if (invoice.status === 'paid') {
return res.status(409).json({ error: 'cannot_delete_paid_invoice' });
}
// 4. Role, live from the database, for an irreversible action.
const actor = await db.users.findById(req.user.id);
if (actor.role !== 'admin' || actor.suspendedAt) {
return res.status(403).json({ error: 'insufficient_permissions' });
}
await db.invoices.softDelete(invoice.id, { by: actor.id });
res.sendStatus(204);
});
// Four checks the gateway structurally cannot perform. That is why
// "the gateway handles auth" is never a complete answer.
Discussion