Skip to main content
Back to Blog
·Updated ·12 min read·By Emanuele Pugliese

State-of-the-Art Multi-Tenant API Authorization with AWS Cognito

Listen to this article

Most multi-tenant platforms start with a simple pattern: verify the JWT, look up the user’s tenant in a database, and pass the result downstream. It works — until you realize every single API call is hitting DynamoDB just to answer “which tenant is this user acting as?” This article walks through how we evolved our API authorizer from a per-request lookup model to a stateless, token-scoped design using Cognito’s Pre Token Generation v2 trigger.

The starting point: resolve at request time

Our platform serves multiple products (Noteble, SystemDox, FaceGov, PropertyHaven) through a single Cognito User Pool. Every backend service shares one Lambda authorizer that accepts either a Cognito JWT or an API key and resolves both to a unified Principal:

@dataclass
class Principal:
    user_id: str        # usr_<cognito_sub>
    tenant_id: str      # ind_<ulid> or org_<ulid>
    role: str           # owner|admin|editor|viewer
    scopes: list[str]   # granted permissions
    auth_method: str    # jwt or api_key
    auth_id: str        # sub or key_id

Handlers call resolve_principal(event) and get a typed object. They never branch on how the caller authenticated. This part of the design is sound — it’s the how of tenant resolution that needed work.

The problems

1. Per-request DynamoDB lookup

For JWT-authenticated requests, the authorizer queried TenantMemberships via a GSI on every call (with a 60-second Lambda-memory cache). That’s a database round-trip on every cold invocation and every cache miss — added latency that scales linearly with traffic.

2. Unsafe API Gateway caching

API Gateway caches authorizer responses keyed on the identity source headers. Our identity source was just Authorization. But we also accepted an X-Tenant-Id header for tenant switching. If a user switched tenants between requests, the cached authorizer response from their previous tenant was served — silently scoping them to the wrong tenant’s data.

3. Fallback heuristic for tenant selection

When no explicit tenant was provided, the authorizer guessed: if the user had one membership, use it; if they had multiple, try the individual tenant (ind_*); otherwise, reject. This implicit resolution is a class of bugs waiting to happen — a user could silently operate in the wrong tenant without realizing it.

4. Empty scopes for JWT principals

JWT-authenticated requests got scopes=[] while API key requests got explicit scopes. This meant handlers couldn’t use a single @requires_scope("docs:write") check — they had to fall back to role checks for human users, creating two authorization paths despite the unified Principal.

The state-of-the-art: encode everything in the token

The modern approach — used by Auth0 Organizations, WorkOS, Clerk, and Stytch — is to make the token itself the proof of tenant membership. The authorizer becomes stateless for the JWT path: it reads claims from the token and never touches the database.

Cognito Pre Token Generation v2

AWS Cognito supports a Pre Token Generation Lambda trigger (v2 since 2023) that runs on every token issuance — login, refresh, and hosted UI flows. It can:

  • Add custom claims to both ID and access tokens
  • Add custom scopes to access tokens
  • Suppress existing claims

This is the hook that makes stateless tenant resolution possible.

The new flow

sequenceDiagram
    participant User
    participant Cognito
    participant PreTokenGen as Pre Token Generation
    participant DynamoDB
    participant APIGateway as API Gateway
    participant Authorizer
    participant Handler

    User->>Cognito: Sign in / refresh token
    Cognito->>PreTokenGen: Trigger (v2)
    PreTokenGen->>DynamoDB: Read memberships + active tenant preference
    DynamoDB-->>PreTokenGen: Membership rows
    PreTokenGen-->>Cognito: Inject claims (tenant_id, role, scopes)
    Cognito-->>User: JWT with tenant-scoped claims

    User->>APIGateway: API call with Bearer JWT
    APIGateway->>Authorizer: Verify JWT
    Note over Authorizer: Read tenant_id, role, scopes<br/>directly from token claims.<br/>No database lookup needed.
    Authorizer-->>APIGateway: Principal + IAM policy
    APIGateway->>Handler: Event with Principal context

What the Pre Token Generation Lambda does

On every token issuance, the Lambda:

  1. Reads the user’s tenant memberships from DynamoDB (same GSI lookup the authorizer used to do, but now it happens once per token, not once per request)
  2. Reads the user’s active tenant preference — stored as a USER#<user_id> | ACTIVE_TENANT item in the same memberships table
  3. Selects the active tenant: explicit preference if set, otherwise the user’s individual tenant. If the user has multiple tenants and no preference, the trigger returns an error — forcing explicit selection via a tenant picker
  4. Maps role to scopes using a deterministic mapping (e.g., owner["*"], editor["docs:read", "docs:write"])
  5. Injects custom claims into the token:
{
  "custom:tenant_id": "ind_01JGXYZ...",
  "custom:tenant_role": "owner",
  "custom:scopes": "docs:read docs:write billing:read"
}

The result: every JWT issued by Cognito is tenant-scoped from birth.

Tenant switching without re-authentication

When a user switches tenants (e.g., from their individual workspace to an organization):

  1. Frontend calls POST /v1/tenant/switch with the target tenant_id
  2. The endpoint validates membership and writes the preference to DynamoDB
  3. Frontend calls Cognito’s InitiateAuth with REFRESH_TOKEN_AUTH to get new tokens
  4. Pre Token Generation runs again, reads the updated preference, injects the new tenant into the token
  5. All subsequent API calls use the new tenant-scoped token

The user never re-enters credentials. The refresh flow is instant. And the authorizer never needs to know about the switch — it just reads the token.

The authorizer becomes stateless

With tenant, role, and scopes embedded in the JWT, the authorizer’s JWT path simplifies dramatically:

def _principal_from_jwt(claims: dict) -> Principal:
    sub = claims['sub']
    tenant_id = claims.get('custom:tenant_id')
    if not tenant_id:
        raise PermissionError('Token missing tenant_id claim')

    role = claims.get('custom:tenant_role', 'viewer')

    scopes_raw = claims.get('custom:scopes', '')
    scopes = scopes_raw.split() if scopes_raw else []

    return Principal(
        user_id=f'usr_{sub}',
        tenant_id=tenant_id,
        role=role,
        scopes=scopes,
        auth_method='jwt',
        auth_id=sub,
        email=claims.get('email', ''),
        name=claims.get('custom:fullName') or claims.get('name', ''),
    )

No DynamoDB query. No cache. No fallback heuristic. The token is the proof.

API Gateway caching becomes safe

Since the tenant is encoded in the JWT itself (and every tenant switch produces a new token via refresh), the API Gateway cache keyed on the Authorization header is now correct. Different tokens = different cache entries = different tenants. The X-Tenant-Id header is no longer needed.

Design decisions

Why not store active tenant as a Cognito custom attribute?

Cognito custom attributes are limited to 50, immutable once created, and require AdminUpdateUserAttributes API calls that need elevated permissions. A DynamoDB item in the existing memberships table is simpler, faster to update, and doesn’t pollute the Cognito schema.

Why not use access token custom scopes instead of ID token claims?

Cognito access token scopes are OAuth2 scopes — they’re meant for resource server authorization. While Pre Token Generation v2 can add custom scopes to access tokens, the ID token is the natural carrier for identity claims like tenant and role. We inject into the ID token claims, which is what the authorizer verifies.

What about the added latency on token issuance?

The Pre Token Generation Lambda adds ~30-80ms to every login and token refresh. In exchange, it removes a DynamoDB lookup from every API call. For a platform where users authenticate once and make hundreds of API calls per session, this is a clear win. The DynamoDB read in Pre Token Generation is the same GSI query the authorizer was doing — we just moved it to a better place in the lifecycle.

What about token expiry and stale tenant data?

If a user’s membership is revoked between token issuances, the token still carries the old tenant claim until it expires (1 hour for our ID tokens). This is the standard JWT trade-off. For immediate revocation, the authorizer can optionally check a short-TTL “revoked memberships” cache — but for most platforms, the 1-hour window is acceptable.

The role-to-scope mapping

One key benefit of this approach is that JWT principals now get explicit scopes, just like API key principals. The mapping is defined centrally:

Role Scopes
owner * (full access)
admin * (full access)
editor docs:read docs:write templates:read templates:write
viewer docs:read templates:read

Handlers can now use a single authorization check regardless of auth method:

@requires_scope("docs:write")
def create_document(event):
    principal = resolve_principal(event)
    # Works for both JWT users and API key callers

Summary of improvements

Aspect Before After
Tenant resolution DynamoDB lookup on every request Read from JWT claims (zero I/O)
Authorizer state Stateful (needs DynamoDB access) Stateless for JWT path
Scopes for JWT users Empty ([]) Populated from role mapping
API Gateway cache Unsafe (tenant in header, not cache key) Safe (tenant encoded in token)
Tenant switching Mutable header on any request Token refresh with new claims
Ambiguous tenant Fallback heuristic (guess individual) Explicit selection required
Authorization model Dual-path (role for JWT, scopes for API key) Unified scopes for both paths
DynamoDB reads per session N (one per API call) 1 (at token issuance)

When this pattern applies

This design works when:

  • You use a token issuer that supports claim customization (Cognito Pre Token Generation, Auth0 Actions, Okta Inline Hooks, Keycloak Protocol Mappers)
  • Your tenant model is membership-based (users belong to tenants with roles)
  • You want stateless API authorization with no per-request database lookups
  • You need safe API Gateway or CDN-level caching of authorization decisions

It doesn’t apply when:

  • Tenant assignment is fully dynamic and must reflect real-time state (e.g., a marketplace where access changes second-by-second)
  • You need sub-second revocation guarantees (though hybrid approaches exist)
  • Your token issuer doesn’t support pre-issuance hooks

For most SaaS platforms, the token-scoped approach is the right default. Move the work to token issuance, keep the hot path stateless, and let the token be the single source of truth for who is calling and in which tenant they’re acting.

Epilogue (July 2026): we reversed the core of this design

Three weeks after publishing this article, we took the role, scopes, and grants claims back out of the token. This epilogue is the honest follow-up: what survived, what didn’t, and why.

The short version: the caveat at the end of this article — “it doesn’t apply when you need sub-second revocation guarantees” — stopped being a footnote and became our requirement. As the platform grew from one product into several sharing a single identity plane, we added the controls a multi-tenant B2B platform eventually needs: suspending a user, suspending or off-boarding an organization, revoking a member, invalidating every session a user holds. Under the fully token-scoped design, every one of those actions has a failure mode shaped like this: the person you just removed keeps their access until their token expires. An hour of “still in” after an offboarding is not a latency trade-off; it’s an incident.

We considered the hybrid mentioned above (a short-TTL revocation blocklist consulted by the authorizer). But once the authorizer is reading any per-request state for correctness, the “stateless” property is already gone — you’re just maintaining a second, inverted copy of the membership table and hoping the two never disagree. So we went the other way.

The token became a pointer

The Pre Token Generation trigger now injects exactly one authorization-relevant claim: custom:tenant_id, the caller’s active tenant. No role. No scopes. No grants. The authorizer verifies the JWT, then resolves everything else at request time from the tenancy registry — one BatchGetItem for three rows (user status, organization status, membership) with a 30-second in-memory cache and a bounded stale-serve window. Suspension, revocation, and membership changes now bite in seconds, on every path, with no second bookkeeping system.

What this costs: one cached DynamoDB read on the authorizer’s hot path — the thing the original design existed to eliminate. Measured against the revocation guarantee it buys, that read is cheap. It is also bounded in a way the token approach’s staleness never was: our worst case is ~60 seconds of stale authorization under a registry outage, versus a full token lifetime in the happy path.

What survived

More of the original design than you might expect:

  • The tenant pointer in the token. The active tenant still travels in the JWT, so API Gateway caching keyed on the Authorization header is still safe, tenant switching is still a token refresh, and the fallback-heuristic class of bugs is still dead. The pointer is simply re-verified against the membership row on every request — a stale or revoked pointer denies instead of being trusted.
  • The unified Principal. Handlers still consume one typed object and never branch on auth method. The scopes unification for JWT principals survived too; scopes are just derived from the registry role at request time instead of at issuance.
  • Pre Token Generation itself. The trigger still resolves the active tenant per product and still does just-in-time provisioning. It stopped being the authorization oracle and became what it should have been all along: the thing that answers “which tenant is this session pointed at?”

The revised advice

If you’re building on Cognito (or Auth0, Okta, Keycloak — the shape is identical), our current position is:

  1. Put the tenant pointer in the token, never the permissions. The pointer changes rarely and controls cache identity; permissions change adversarially — on firings, breaches, and offboarding — and must not be frozen into a signed artifact you cannot recall.
  2. Resolve permissions in the authorizer with a short-TTL cache, and fail closed. A three-row point read with a 30-second cache is not the performance problem it’s reputed to be; it’s a rounding error next to your P50, and it converts “revocation eventually” into “revocation now”.
  3. Treat “stateless authorization” as a spectrum, not a virtue. The valuable properties — safe response caching, no guessing heuristics, unified principals — come from the pointer, not from freezing the entire authorization decision at issuance. Keep the part of statelessness that pays rent.

The original article stands as an accurate account of the token-scoped pattern and of why we adopted it. But state-of-the-art turned out to have a second act: for a multi-product platform with real off-boarding requirements, the token is the right place for identity and the wrong place for authority.

Related Articles

Comments

Comments are powered by GitHub Discussions. Sign in with GitHub to leave a comment.

Basket is empty

Stay in the loop

Get architecture insights, product updates, and practical engineering tips.

PuglieseWeb LTD is registered in England and Wales. Company No. 17078772.