Skip to main content

Sessions

WIP

The 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 successful resolveSession() bumps lastUsedAt.
  • 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 requires freshness: true, the session's lastReauthAt must be within this window - otherwise the call returns AUTH/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:

EventAction
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 changerotateOrCreate for all sessions of the identity.
Identity merge or unlinkRevoke every session of the absorbed/unlinked identity.
Hijack policy fires with rotaterotateOrCreate (preserves AAL).
Hijack policy fires with revokerevokeAllForIdentity.
Sign-outrevoke 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, returns null (and emits an audit event).
  • requestSnapshot - feeds the anomaly detectors. Without it, anomaly results in null and 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

EventWhen
session.createdBrand-new session from create() or first call to rotateOrCreate() on no prior session.
session.rotatedAny subsequent rotateOrCreate() - privilege change.
session.revokedrevoke() or revokeAllForIdentity().
session.impersonate-startImpersonation begins.
session.impersonate-releaseImpersonation ends.

Subscribe to wire audit log delivery:

auth.events.on('session.rotated', (event) => {
  audit.log('session_rotated', { ...event })
})