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:
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