Sessions
WIPThe 7-row rotation matrix, session lifecycle, freshness windows, absolute TTL, and how privilege changes force rotation.
The sessions facet
auth.sessions owns the entire session lifecycle. It is the only code
path that should create or rotate a session - flows, providers, MFA, and
hijack policy all delegate here.
const session = await auth.sessions.create({
identityId,
aal: 'aal2', // RP-set assurance level
amr: ['pwd', 'totp'], // RFC 8176 methods
transportIntents: [...], // intents the transport emits back
})
await auth.sessions.rotateOrCreate({ session, reason: 'mfa-passed' })
await auth.sessions.revoke({ sessionId })
Lifetime tuning
new AuthEngine({
session: {
ttlMs: 7 * 24 * 60 * 60 * 1000, // sliding window per use
absoluteTtlMs: 30 * 24 * 60 * 60 * 1000, // hard cap regardless of activity
freshnessMs: 5 * 60 * 1000, // step-up window for high-risk actions
},
})
ttlMs- the rolling expiration. Each successfulresolveSession()bumpslastUsedAt.absoluteTtlMs- the absolute cap. Even an actively-used session is revoked once it crosses this point; the user must re-authenticate.freshnessMs- the step-up window. When a sensitive action requiresfreshness: true, the session'slastReauthAtmust be within this window - otherwise the call returnsAUTH/STEP_UP_REQUIRED.
The rotation matrix
duck-auth's session-fixation defence routes every privilege change
through a single rotation routine. The decision table from DESIGN.md section 37:
| Event | Action |
|---|---|
Successful first-factor (password, magic-link, oauth, passkey, saml) | rotateOrCreate - old session ID is invalidated. |
| MFA passed (TOTP, backup code, WebAuthn-MFA) | rotateOrCreate - AAL upgraded. |
| Email or password change | rotateOrCreate for all sessions of the identity. |
| Identity merge or unlink | Revoke every session of the absorbed/unlinked identity. |
Hijack policy fires with rotate | rotateOrCreate (preserves AAL). |
Hijack policy fires with revoke | revokeAllForIdentity. |
| Sign-out | revoke for the current session. |
The takeaway: the session id you saw at signup is never the session id you carry after MFA, password change, or impersonation release.
Resolving sessions
const ctx = await auth.resolveSession(req, {
expectedTenantId: 'org-123',
requestSnapshot: { ip, userAgent, geo },
})
if (!ctx) return new Response('Unauthorized', { status: 401 })
const { session, identity, anomaly } = ctx
expectedTenantId- guards against cross-tenant leaks. If the resolved session belongs to a different tenant, returnsnull(and emits an audit event).requestSnapshot- feeds the anomaly detectors. Without it, anomaly results innulland the request proceeds.
Step-up
import { AuthError } from '@gentleduck/auth/core'
async function deleteAccount(req: Request) {
const ctx = await auth.resolveSession(req)
if (!ctx) throw new AuthError('AUTH/UNAUTHENTICATED')
// High-risk action: require a fresh re-auth within the last 5 minutes.
if (Date.now() - ctx.session.lastReauthAt > auth.config.session.freshnessMs) {
throw new AuthError('AUTH/STEP_UP_REQUIRED')
}
// Or: require MFA at AAL2+
if (ctx.session.aal !== 'aal2') {
throw new AuthError('AUTH/AAL_INSUFFICIENT')
}
// Proceed...
}
Listing and revoking
const sessions = await auth.sessions.listForIdentity(identityId)
await auth.sessions.revoke({ sessionId })
// Bulk: revoke every session for an identity (e.g. on password change)
await auth.sessions.revokeAllForIdentity(identityId, { except: currentSessionId })
The bulk revoke accepts except so the actor's current session survives
the revoke storm - common after a password change initiated by the user.
Events emitted
| Event | When |
|---|---|
session.created | Brand-new session from create() or first call to rotateOrCreate() on no prior session. |
session.rotated | Any subsequent rotateOrCreate() - privilege change. |
session.revoked | revoke() or revokeAllForIdentity(). |
session.impersonate-start | Impersonation begins. |
session.impersonate-release | Impersonation ends. |
Subscribe to wire audit log delivery:
auth.events.on('session.rotated', (event) => {
audit.log('session_rotated', { ...event })
})