Skip to content

ADR-065: Cognito custom claims as session state carrier

Status Accepted
Date 2026-05-15
Deciders CTO, Head of Platform Engineering
Affects repos bank-app, bank-platform

Status: Accepted — 2026-05-15

Context

MOD-068 (authentication) issues Cognito tokens on sign-in and simultaneously creates a session record in app.sessions in the bank Postgres database. The session record carries fields that Cognito does not include in a standard JWT: party_id (the bank's internal customer UUID), jurisdiction (NZ or AU), mfa_completed, and step-up flags.

Because these fields live only in the session table, every auth-sensitive handler must retrieve them at runtime. The current pattern in SD08 (bank-app): the handler invokes MOD-068's validate-session Lambda synchronously, which queries app.sessions and returns the fields. This adds 50–200ms latency to every authenticated request, creates a cold-start dependency on MOD-068 in every SD08 Lambda, and duplicates work already done by the Lambda authorizer — which validated the Cognito JWT moments before.

Cognito supports custom attributes on the user pool and a pre_token_generation Lambda trigger that runs during every token issuance (sign-in and token refresh). The trigger can inject arbitrary claims into both the ID token and access token before they are issued. The Lambda authorizer validates the token and forwards all claims through event.requestContext.authorizer. Handlers receive session context from requestContext with no additional network hop.

This decision follows the cross-repo complexity review conducted 2026-05-15.


Decision

Session-relevant fields are embedded as Cognito custom claims at token issuance time via a pre_token_generation Lambda trigger owned by MOD-068. The Lambda authorizer validates and forwards these claims. Handlers read from requestContext — no runtime invocation of MOD-068 is required for standard authenticated requests.

Custom claims added to the token

Claim Type Source Notes
custom:party_id UUID string Party lookup at sign-in Bank's internal customer UUID
custom:jurisdiction NZ | AU Party record Registered jurisdiction
custom:mfa_level NONE | OTP | BIOMETRIC Completed auth factors Highest factor completed this session
custom:session_id UUID string Generated at sign-in Ties the token to a specific session; used for revocation

Token lifetime

Access token TTL: 15 minutes. Refresh token TTL: 24 hours with rotation. The short access token TTL bounds the staleness window for embedded claims — if a customer's jurisdiction changes (rare), the stale claim expires within 15 minutes and the refreshed token carries the correct value.

app.sessions retained as audit log

The app.sessions table is retained but no longer the primary session state carrier. It records session lifecycle events: sign-in time, device fingerprint, last token refresh, and sign-out. It is written by MOD-068 on session events and queried only for audit, device management, and the "active sessions" management screen. It is not read on the request hot path.

Step-up authentication

MOD-068's step-up flow issues a new, short-lived access token (5-minute TTL) with custom:mfa_level elevated. The Lambda authorizer inspects this claim for endpoints that declare a step-up requirement. No runtime Lambda invoke needed; the authorizer validates the claim locally.

Token revocation

The Lambda authorizer checks custom:session_id against a JTI blocklist on every request. The blocklist moves from DynamoDB (mod-044-revocation table) to a Postgres table provisioned by MOD-103 in the bank database. The revocation check is a single indexed lookup:

SELECT 1 FROM auth.revoked_tokens
 WHERE session_id = $1 AND expires_at > now()

A scheduled cleanup Lambda deletes expired rows nightly. DynamoDB TTL semantics are not needed.

Handler change

Handlers replace the runtime invoke pattern:

// Before — Lambda invoke on every request
const { party_id, jurisdiction, mfa_level } = await validateSession(event);

with a read from request context:

// After — zero network hops; claims validated by authorizer
const { party_id, jurisdiction, mfa_level } =
  event.requestContext.authorizer as SessionClaims;

session-client.ts and the validate-session invoke are removed. step-up-client.ts SSM ARN polling is replaced with an environment variable injected at deploy time per the module shared library standard.


Alternatives considered

Keep session table; cache in Lambda module scope. Module-level caching avoids the invoke after the first request on a warm container. Rejected: still fails cold start; requires MOD-068 to be warm; cache invalidation on session revocation is complex.

Keep DynamoDB for revocation list. DynamoDB TTL avoids a cleanup job. Rejected: adds a second operational data store; the bank Postgres database (ADR-064) handles the indexed lookup at equivalent latency; operational surface is reduced.

Store session fields in DynamoDB alongside revocation. DynamoDB lookup replaces Lambda invoke. Rejected: adds DynamoDB to the hot path for data that is already in the token; Postgres for revocation check is sufficient.


Consequences

Positive: - The Lambda authorizer + custom claims satisfies the full session context requirement in a single synchronous JWT validation. Zero additional network hops per authenticated request. - MOD-072 and all other auth-sensitive SD08 handlers drop their MOD-068 runtime dependency. Cold-start fan-out risk is materially reduced. - app.sessions becomes an append-only audit log — simpler schema, no read contention on the hot path. - MOD-044's DynamoDB mod-044-revocation table is replaced by auth.revoked_tokens in the bank database, eliminating a second operational data store from the stack. - Step-up flows simplify: a short-lived elevated token replaces the session-table flag pattern.

Negative / risks: - Claims are embedded at token issuance. A customer's jurisdiction cannot change mid-session without a re-login or token refresh. Accepted: jurisdiction changes are exceptional; the 15-minute access token TTL bounds exposure. - Cognito ID token size increases by ~120 bytes per session. Well within Cognito's 6KB token limit. - The pre_token_generation trigger adds ~10ms to sign-in latency. Acceptable; sign-in is not the hot path. - The auth schema requires provisioning by MOD-103 alongside the existing domain schemas.


All ADRs Compiled 2026-05-22 from source/entities/adrs/ADR-065.yaml