Validating Tenant Claims at the API Gateway

The gateway is the last place a request is untrusted and the first place tenant identity can be established for everything downstream, and this page shows exactly which checks belong there. It is a focused part of Tenant-Aware JWT Token Management: what to verify, what to normalise, and what to strip before a request reaches any service.

Problem Framing

In a service architecture, every hop that re-derives tenant identity is a hop that can derive it differently. Validate the token in the gateway, in the BFF, and again in three microservices, and you have five implementations of the same rule โ€” which means five chances for one of them to accept an unsigned token, skip the audience check, or trust a header the client supplied.

The alternative is a single verification boundary. The gateway validates the token cryptographically, checks that the tenant claim is consistent with the host the request arrived on, and rewrites the result into a small set of trusted internal headers. Downstream services never parse a token; they read X-Tenant-Id and treat it as verified, which is safe precisely because the gateway strips any inbound copy of that header first.

The failure this prevents is the most common cross-tenant vulnerability in service architectures: a client sets X-Tenant-Id: victim on its own request, an internal service reads it because "it comes from the gateway", and the boundary is gone. Stripping is not a detail โ€” it is the control that makes the whole pattern sound.

Step-by-Step Guide

1. Strip client-supplied trust headers before anything else

This must be the first rule in the chain, before authentication, before routing, before rate limiting โ€” because a later rule that errors out must not fall through to a request that still carries the spoofed header.

# envoy-ish gateway policy โ€” order matters, strip is rule zero
http_filters:
  - name: strip_client_trust_headers
    typed_config:
      request_headers_to_remove:
        - x-tenant-id
        - x-tenant-tier
        - x-user-id
        - x-actor-id
        - x-internal-trace-authority
  - name: jwt_authn
  - name: tenant_host_binding
  - name: router

2. Verify the token against a cached key set, keyed by kid

Fetching the key set per request is a latency and availability problem; never fetching it again is a rotation problem. Cache by key identifier with a bounded TTL and a single-flight refresh on an unknown kid.

// verify.ts โ€” gateway-side token verification
import { createRemoteJWKSet, jwtVerify } from 'jose';

const jwks = createRemoteJWKSet(new URL(process.env.JWKS_URL!), {
  cooldownDuration: 30_000,     // do not stampede on an unknown kid
  cacheMaxAge: 10 * 60_000,
});

export async function verifyToken(token: string, expectedIssuer: string) {
  const { payload } = await jwtVerify(token, jwks, {
    issuer: expectedIssuer,
    audience: 'api.example.com',
    algorithms: ['EdDSA', 'RS256'],   // explicit allow-list; never read alg from the header
    clockTolerance: 30,
  });
  if (typeof payload.tid !== 'string') throw new Error('missing tenant claim');
  return payload as { sub: string; tid: string; scope?: string; act?: { sub: string } };
}

Pinning the algorithm list is what closes the alg: none and RS256-to-HS256 confusion classes; the key rotation behaviour that makes an unknown kid a normal event rather than an outage is covered in rotating tenant-specific JWT signing keys.

3. Bind the tenant claim to the request's host

A valid token for tenant A presented to tenant B's hostname is a real attack path on subdomain-per-tenant platforms. The gateway is the only component that reliably sees both facts, so it is where they must be compared.

// host-binding.ts
export function assertHostMatchesTenant(host: string, tid: string, map: HostMap) {
  const expected = map.tenantForHost(host);        // from the tenant registry cache
  if (!expected) throw new HttpError(404, 'unknown host');
  if (expected !== tid) {
    metrics.increment('gateway.tenant_host_mismatch', { host });
    throw new HttpError(403, 'tenant/host mismatch');
  }
}

For path-based tenancy the same check applies to the first path segment; for header-based tenancy there is nothing to bind against, which is one reason header-based tenant selection is the weakest of the three.

4. Normalise the verified claims into a minimal internal contract

Downstream services should receive the smallest set of facts they need, in a stable shape, so a change to the token format does not ripple through every service.

// inject.ts
export function internalHeaders(claims: VerifiedClaims): Record<string, string> {
  const h: Record<string, string> = {
    'x-tenant-id': claims.tid,
    'x-user-id': claims.sub,
    'x-auth-epoch': String(claims.epoch ?? 0),
  };
  if (claims.act?.sub) h['x-acting-staff-id'] = claims.act.sub;   // impersonation stays visible
  if (claims.scope) h['x-scope'] = claims.scope;
  return h;
}

Propagating act.sub separately is what keeps impersonated traffic attributable all the way to the audit trigger, rather than collapsing into the impersonated user's identity.

5. Fail closed, and make the failure legible

An unverifiable token must never fall through to an anonymous or default tenant. Return 401 for anything that fails verification, 403 for a verified token in the wrong place, and log the reason with enough detail to debug without echoing the token.

// gateway-handler.ts
try {
  const claims = await verifyToken(bearer, issuerFor(host));
  assertHostMatchesTenant(host, claims.tid, hostMap);
  forward(req, internalHeaders(claims));
} catch (err) {
  log.warn('gateway.auth_rejected', {
    host, path: req.path, reason: (err as Error).message, kid: peekKid(bearer),
  });
  respond(err instanceof HttpError ? err.status : 401);
}

Verification

Three tests cover the behaviour that actually breaks: spoofed headers, cross-host replay, and algorithm confusion.

// gateway.spec.ts
it('strips a client-supplied tenant header', async () => {
  const res = await gw.get('/api/echo-headers')
    .set('Host', 'acme.api.test')
    .set('Authorization', `Bearer ${tokenFor('acme')}`)
    .set('X-Tenant-Id', 'globex');
  expect(res.body['x-tenant-id']).toBe('acme');     // gateway value, not the client's
});

it('rejects a valid token presented on another tenant host', async () => {
  const res = await gw.get('/api/me')
    .set('Host', 'globex.api.test')
    .set('Authorization', `Bearer ${tokenFor('acme')}`);
  expect(res.status).toBe(403);
});

it('rejects an unsigned token', async () => {
  const res = await gw.get('/api/me')
    .set('Host', 'acme.api.test')
    .set('Authorization', `Bearer ${noneAlgToken('acme')}`);
  expect(res.status).toBe(401);
});

Complement the tests with a standing production check: services should never see an inbound Authorization header, and the mismatch counter should be flat at zero.

SELECT service, count(*) AS saw_authorization_header
FROM   service_request_log
WHERE  has_authorization_header AND at > now() - interval '1 day'
GROUP  BY service;
-- expected: no rows

Failure Modes & Gotchas

FAQ

Should services re-verify the token as well as trusting the gateway header? Defence in depth argues yes, but the cost is the duplication this pattern exists to remove. The pragmatic middle ground is that services trust the header for tenant scope while verifying that the request actually came from the gateway โ€” mutual TLS, a service mesh identity, or a short-lived signed forwarding assertion. That gives you a second independent check without a second token implementation.

Where should authorization decisions be made? The gateway is the right place for coarse checks: is this token valid, does it belong on this host, is the scope broad enough for this route. Fine-grained decisions โ€” can this user edit this specific record โ€” need business context the gateway does not have, and belong in the service alongside the data. Trying to push per-object rules into gateway configuration produces a policy file nobody can reason about.

What if a token legitimately spans several tenants? Prefer one tenant per token: a user with access to three workspaces gets three sessions, or exchanges their identity token for a tenant-scoped one at the point of entry. A multi-tenant claim array forces every downstream service to re-derive which tenant this particular request concerns, which is exactly the re-derivation the single verification boundary is designed to eliminate.

What should happen to requests during a key-service or identity-provider outage? Verification should keep working from the cached key set, because the keys change rarely and the cache exists precisely for this. What stops is issuing new tokens, so existing sessions continue while new sign-ins fail โ€” a degradation most customers find acceptable if it is explained. What must not happen is a fallback that skips verification when the key set cannot be refreshed; an availability incident should never quietly become an authentication bypass. Set the cache lifetime long enough that a provider outage of ordinary length is invisible to authenticated traffic, and alert on refresh failure so the outage is noticed before the cache expires.