Skip to content

PATTERN Cited by 2 sources

JWT tenant-claim extraction

Shape

Never accept tenantId from the request. Always extract it from the validated identity token's immutable claim. The request body, URL path, headers, and query parameters are treated as untrusted input; the only authoritative source of tenant context is the signed JWT.

Canonical AWS realization with Cognito:

1. User authenticates to Cognito → JWT issued with:
     "custom:tenantId": "tenant-a"   (immutable custom attribute)
     "custom:role":     "admin"      (mutable)

2. Every API request carries:  Authorization: Bearer <JWT>

3. Service-side:
   a. Validate JWT signature against Cognito JWKS
   b. Extract custom:tenantId claim → request.context.tenantId
   c. **Discard any tenantId field in request body / path / query**
   d. All downstream data access uses the JWT-sourced tenantId

(Source: sources/2026-04-08-aws-build-a-multi-tenant-configuration-system-with-tagged-storage-patterns §C — "Critical security design: the service never accepts tenantId from request parameters. Instead, it extracts the tenant context from validated JWT tokens.")

Why this specific discipline matters

An attacker with a valid tenant-A JWT who manipulates the request body to reference tenantId: "tenant-b" is the canonical multi-tenant exfiltration scenario. Three classes of mitigation:

  1. Check that request tenantId == JWT tenantId and reject mismatches. Adequate but requires discipline at every endpoint + catches only explicit mismatches. Silent drift across a large codebase is possible.
  2. Use request-body tenantId for routing, log suspicious cases. Defense via detection only. Attackers get their first try for free.
  3. Ignore request-body tenantId entirely; always use JWT's. The attack surface vanishes: manipulating the request can't change which tenant's data the service reads, because the service doesn't read the request for that information.

Option (3) is structurally safer than (1) — the class of "forgot to check this endpoint" bugs is eliminated, not defended against. It's the pattern AWS explicitly recommends for multi-tenant SaaS.

What makes this actually work

  • Cryptographic integrity of the tenant claim. The JWT is signed by Cognito; the client cannot forge or modify claims. The tenant binding is cryptographically verifiable on every request.
  • custom:tenantId is declared immutable at user-pool creation. This is Cognito-specific: custom attributes marked mutable: false cannot be changed after user creation, even by a user-pool admin. Tenant-membership changes require re-provisioning the user (or using a mutable secondary attribute with a migration path).
  • JWT validation is nonnegotiable. Validate signature against JWKS, exp, iss, aud. Skipping any is how "JWT tenant extraction" becomes "attacker-controlled tenant extraction".
  • JWT claims are pinned for token lifetime. This is the tradeoff: tenant-membership changes don't take effect until the token refreshes. Short token lifetimes (or forced logout on membership change) are the mitigation.

Interaction with pre-token-generation hook

The pre-token hook is the write-side partner of this pattern: it injects tenantId into the JWT at issue time, typically by looking up a user-to-tenant mapping in DynamoDB. JWT tenant-claim extraction is the read-side discipline: downstream services trust only what the hook signed into the token.

Together they form the full identity-layer tenant-isolation chain:

Cognito pool (per-tenant or shared)
  → User login
  → Pre-token Lambda hook: fetch user's tenantId, inject as custom claim
  → Cognito signs JWT
  → Every API call: Authorization: Bearer <JWT>
  → Backend: validate JWT, extract tenantId from claim, *never from body*
  → Query backend data scoped by JWT-sourced tenantId

Position in the tenant-isolation stack

JWT tenant-claim extraction is the identity-layer link in a layered tenant-isolation architecture (see concepts/tenant-isolation):

Layer Mechanism Canonical instance
Identity Cognito custom immutable attribute + JWT claim This pattern
Token Pre-token hook signs tenantId into JWT patterns/pre-token-generation-hook
Authorization Per-tenant policy store patterns/per-tenant-policy-store
Request-routing Authorizer propagates tenantId in request header patterns/lambda-authorizer
Data DynamoDB composite key TENANT#{id} / RDS tenant-context requirement

Each layer re-enforces tenant boundaries; a bug at any one doesn't leak data across tenants. JWT tenant-claim extraction is the must-have entry gate — it's what makes all downstream enforcement cryptographically grounded rather than trust-based.

When to deviate

  • Admin operations that legitimately cross tenants (platform admin looking at tenant A + tenant B) require an explicitly different identity shape — either a dedicated admin role in the same pool with tenantId: "*" interpreted specially, or a separate admin identity source. Never special-case this by accepting tenantId from admin requests.
  • Multi-tenant batch jobs (nightly aggregations across tenants) need a service principal with multi-tenant access; they don't use user JWTs, they use service credentials with scoped IAM permissions.

Caveats

  • Tenant-membership change latency. Users don't see the new tenant until their JWT refreshes (typically 1 hour). For time- sensitive changes (tenant offboarding), force a logout.
  • Session-fixed tenantId. A user who belongs to multiple tenants needs a multi-tenant identity model or per-tenant JWTs (one user pool per tenant), which complicates pool-switch UX.
  • Cognito custom-attribute limits. Cognito caps custom attributes per pool (25 at the time of the source post). If you need more than a handful of tenant-scoped identity attributes, consider fetching them via the pre-token hook into a bounded set of claims.
  • Token size. Cognito JWTs have a size ceiling (~16KB); rich per-tenant claim payloads can bump into this. Keep the tenant claim small and look up details server-side by tenantId when needed.

Seen in

Last updated · 200 distilled / 1,178 read