JWT Claims for Tenant Scoping Best Practices

A tenant-scoped JWT must carry exactly one immutable tenant identifier, validated cryptographically at every hop, so that no request can read or write data outside its tenant boundary. This page sits inside Tenant-Aware JWT & Token Management and covers how to shape the claim set, where to validate it, and how to version and revoke it without bloating the token.

Problem Framing

A JWT is a bearer credential: whoever holds it is trusted for whatever it asserts. In a single-tenant app that assertion is just "who you are." In a multi-tenant SaaS it also has to answer "whose data may you touch," and getting that second half wrong is how cross-tenant leakage happens. The failure is rarely a broken signature. It is a service that signs the token correctly, then derives the tenant from somewhere other than the verified payload — an X-Tenant-ID header, a path segment, a cached lookup keyed on sub — and a single mismatch grants Tenant A a query that returns Tenant B's rows.

The decision that matters is therefore where tenant scope lives and where it is read. If tenant_id is a top-level, immutable claim inside the signed payload, and every layer reads it from that one place, the boundary is enforced by the same cryptography that protects identity. If tenant scope is reconstructed from request metadata after verification, the signature protects nothing useful.

The second recurring problem is staleness. Claims are a snapshot taken at issue time. When a user is removed from a tenant, downgraded, or the tenant is suspended for non-payment, any unexpired token still asserts the old state. Short lifetimes plus a version claim bound the window; rotating signing keys narrows it further, which is why claim design and rotating tenant-specific JWT signing keys are part of the same lifecycle problem.

Step-by-Step Guide

1. Place tenant_id as a top-level immutable claim

Keep sub for user identity and tenant_id for tenant scope. Do not nest tenant data inside an object, and do not overload sub with a composite key. Use a high-entropy identifier (UUIDv4 or ULID) so IDs cannot be enumerated.

import jwt from "jsonwebtoken";

const TENANT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;

export function issueTenantToken(userId: string, tenantId: string, roles: string[], claimVer: number): string {
  if (!TENANT_ID.test(tenantId)) throw new Error("tenant_id must be a UUIDv4");
  const now = Math.floor(Date.now() / 1000);
  return jwt.sign(
    { sub: userId, tenant_id: tenantId, roles, claim_ver: claimVer, iat: now, exp: now + 15 * 60 },
    process.env.JWT_PRIVATE_KEY as string,
    { algorithm: "RS256", issuer: "auth.example.com" }
  );
}

2. Scope roles to the active tenant only

Roles in the token must apply to the tenant_id in the same token. Never ship a global role array spanning every tenant a user belongs to — that invites lateral movement if a downstream service forgets to filter. For users in many tenants, issue one token per active tenant session rather than one token listing all of them.

{
  "sub": "9f2a1c0e-...-user",
  "tenant_id": "3b7d4e21-...-tenant",
  "roles": ["billing.read", "members.invite"],
  "claim_ver": 7,
  "iat": 1718900000,
  "exp": 1718900900
}

3. Verify the signature and extract tenant scope at the gateway

The gateway is the only place allowed to turn raw bytes into a trusted tenant_id. Pin the algorithm to RS256, validate iss, and reject any token missing tenant_id. Strip inbound X-Tenant-ID and similar headers here so no later layer can read them by accident.

const jwt = require("jsonwebtoken");

function verifyTenantToken(req, res, next) {
  delete req.headers["x-tenant-id"];
  const token = req.headers.authorization?.split(" ")[1];
  if (!token) return res.status(401).json({ error: "missing token" });
  try {
    const decoded = jwt.verify(token, process.env.JWT_PUBLIC_KEY, {
      algorithms: ["RS256"],
      issuer: "auth.example.com",
    });
    if (!decoded.tenant_id) return res.status(403).json({ error: "no tenant scope" });
    req.tenant = { id: decoded.tenant_id, roles: decoded.roles || [], ver: decoded.claim_ver };
    next();
  } catch {
    res.status(403).json({ error: "invalid or expired token" });
  }
}

4. Reconcile claim_ver in service middleware

A short TTL is not enough on its own. Keep a per-tenant current policy version in a fast cache (Redis) and reject tokens whose claim_ver is behind it. This forces a refresh the moment roles, billing status, or isolation rules change, without waiting for natural expiry.

import redis
from fastapi import HTTPException, Request

cache = redis.Redis(host="cache", decode_responses=True)

def require_current_claims(request: Request) -> dict:
    ctx = request.state.tenant  # set by gateway-equivalent dependency
    current = int(cache.get(f"policy_ver:{ctx['id']}") or 0)
    if ctx["ver"] < current:
        raise HTTPException(status_code=403, detail="stale claims, refresh token")
    return ctx

5. Push the tenant filter into the data layer

Every query must be filtered by the verified tenant_id. Bind it as a parameter; never interpolate it into SQL. Where the database supports it, set a session variable and let row-level security enforce the predicate so a forgotten WHERE clause cannot leak rows.

-- run once per request, after gateway verification, before any tenant query
SET LOCAL app.tenant_id = '3b7d4e21-...-tenant';

-- RLS policy enforces the boundary regardless of the application query
CREATE POLICY tenant_isolation ON invoices
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

6. Keep mutable data out of the token

tenant_id and sub are immutable and belong in the token. Tenant display name, plan tier, feature flags, and large permission sets are mutable and belong in a cache lookup keyed by tenant_id. Storing them in the JWT bloats the header and guarantees stale data the moment they change. If a permission set is large, ship a reference (a role name or version) and resolve it server-side.

Verification

Confirm that scope comes only from the signed payload and that stale tokens are rejected. The decode below should fail on a tampered tenant_id, and the middleware should reject a token whose claim_ver trails the cached policy version.

# decode without verifying to inspect claims (debugging only)
echo "$JWT" | cut -d. -f2 | base64 -d 2>/dev/null | jq '{sub, tenant_id, roles, claim_ver}'
import jwt, pytest

def test_tampered_tenant_id_is_rejected(signed_token, public_key):
    head, body, sig = signed_token.split(".")
    forged = f"{head}.{body[:-3]}AAA.{sig}"
    with pytest.raises(jwt.InvalidSignatureError):
        jwt.decode(forged, public_key, algorithms=["RS256"], issuer="auth.example.com")

def test_stale_claim_ver_blocked(client, stale_token):
    res = client.get("/v1/invoices", headers={"Authorization": f"Bearer {stale_token}"})
    assert res.status_code == 403

A correct deployment logs sub plus tenant_id on every authorized request, so an audit trail can prove which user touched which tenant.

Failure Modes & Gotchas

FAQ

Should I use a custom claim or a standard OIDC claim for the tenant ID? Use a custom top-level claim such as tenant_id (or tid). Standard OIDC fields like sub, org, or azp carry provider-specific semantics that vary between identity providers, so reusing them invites parsing drift and accidental collisions.

How do I handle a user who belongs to multiple tenants? Issue one token per active tenant session and switch context explicitly, or include a tenants array of scoped role sets and validate the chosen tenant_id on every request. A single flat global role list is the pattern to avoid, because it enables lateral movement.

Can I revoke a token for one tenant without invalidating every token a user holds? Yes. Bump the per-tenant claim_ver in the cache to invalidate all of that tenant's outstanding tokens on the next request, or maintain a tenant-scoped jti denylist with a TTL matching the token lifetime. Short access-token lifetimes keep the blast radius small either way.