Auth / security runbook
Companion to auth-security-review.md.
Ops procedures for the auth surface built out in Phases 1–4.
Envs to set in production
| Env | Purpose | Set-once vs rotatable | Notes |
|---|---|---|---|
JWT_KEYS | JSON array of {kid, alg, active, secret | publicKey | privateKey}. Active key signs; every key verifies. | Rotatable | RS256 recommended — see rotation procedure below. |
JWT_ACCESS_SECRET | Legacy HS256 fallback | Deprecated | Loaded as synthetic kid="legacy" when JWT_KEYS is unset. |
SECRETS_KEY | AES-256-GCM key for SecretsCipher (integration secrets, webhook keys). | Long-lived | Rotating invalidates existing ciphertext; needs a data-migration. |
PII_HASH_KEY | HMAC-SHA256 key for PiiCipher deterministic hashes. | Never rotate | Rotating invalidates every hash — breaks phone-hash lookups, dedup. |
MFA_KEY | AES-256-GCM key for MFA seed encryption. | Long-lived | Rotating invalidates existing enrolments — users re-enrol. |
MFA_HASH_KEY | HMAC-SHA256 key for MFA recovery-code hashes. | Long-lived | Rotating invalidates existing recovery codes — users regenerate. |
CLAIM_PIN_HMAC_KEY | HMAC-SHA256 key for public-claim-lookup PIN. | Long-lived | Rotating invalidates existing claim PINs. |
PRIVILEGED_ALLOWED_CIDRS | Comma-separated CIDR list for privileged JWT routes. | Rotatable | Empty = no-op (dev). Set to office + VPN egress for prod. |
GEOIP_LOOKUP_URL + GEOIP_LOOKUP_KEY | Merchant-controlled geo-IP endpoint. | Rotatable | Empty = country drift detection disabled. |
HIBP_ENABLED=true | Enable Have-I-Been-Pwned range check on password set. | Rotatable | Egress to api.pwnedpasswords.com. Opt-in. |
PASSWORD_MAX_AGE_DAYS_ADMIN=90 | Password expiry for privileged roles. | Rotatable | Default 90. |
PASSWORD_MAX_AGE_DAYS_USER=180 | Password expiry for standard roles. | Rotatable | Default 180. |
AUTH_IMPOSSIBLE_TRAVEL_WINDOW_HOURS=4 | Cross-country login window. | Rotatable | Default 4h. |
CLAIM_ACK_SLA_DAYS=14 / CLAIM_DECISION_SLA_DAYS=30 | Insurance Act 2017 §88–95 SLAs. | Rotatable | Change requires a claim-team communication. |
JWT key rotation
Zero-downtime rotation of the RS256/HS256 signing key. Requires
JWT_KEYS set (not the legacy JWT_ACCESS_SECRET mode).
Time budget: 5 min to introduce the new key + one full access-token TTL (~15 min default) before the old key can be safely removed.
-
Generate the new keypair (RS256 example):
openssl genrsa -out new.pem 2048openssl rsa -in new.pem -pubout -out new.pub -
Add the new key as inactive in
JWT_KEYS:[{ "kid": "prod-2026-a", "alg": "RS256", "active": true,"privateKey": "...", "publicKey": "..." },{ "kid": "prod-2026-b", "alg": "RS256", "active": false,"privateKey": "...", "publicKey": "..." }] -
Deploy and restart. All access tokens still signed with the old key (
prod-2026-a), but the new key is loaded and can verify. -
Flip
activeon the new key, redeploy. New tokens now signed withprod-2026-b; old tokens still verified byprod-2026-a. -
Wait one access-token TTL (default 15 min) so every in-flight old token expires naturally.
-
Remove the old key from
JWT_KEYS, redeploy. Old tokens now fail verification withTOKEN_INVALID— expected.
Emergency compromise: if the old private key leaks, skip the
15-min wait and go straight to step 6. Every in-flight session on
the old key gets kicked to /auth/login and re-authenticates
(users notice a single sign-in prompt).
Incident response
Leaked JWT secret / private key
- Trigger key rotation (procedure above), skipping the 15-min wait.
- Query
security_eventsfor the last 24 h oflogin_successevents from unusual IPs / countries — sample any that don't match usual patterns and force-logout viaPOST /auth/logout-allon the affecteduserId. - Rotate the
SECRETS_KEYif the leaked value wasJWT_ACCESS_SECRETin fallback mode — same key means integration secrets are also compromised.
Compromised admin account
- Suspend the account:
PATCH /users/:id { status: 'suspended' }. POST /users/:id/rolesDELETE of every privileged role.POST /auth/logout-all(impersonated) — nukes every refresh-token family for the user.- Audit trail: query
AuditEventwhereactorId = compromised-user,createdAt > when compromise started. Every mutation is on the hash-chained trail — file a rollback plan. - Rotate any secrets the account could have exfiltrated
(
SecretsCipherpayloads visible to that user's role).
Refresh-token theft (single user)
Phase 1 reuse detection auto-revokes the family. If the family shows
reuseDetectedAt on the last row, the platform has already contained
it — the legitimate user will see REFRESH_REUSED and re-authenticate.
No ops action required unless the same user is repeatedly targeted
(check the login_burst_across_users fraud alert).
Refresh-token theft (mass event / stolen device)
POST /auth/logout-all (impersonated via admin session).
Credential-stuffing burst
Watch for FraudAlert rows with ruleType = login_burst_across_users.
Threshold defaults: 20 total failures across 5 distinct users from one
IP in 1 h. On alert:
- Block the IP at the edge (WAF / nginx).
- Query
SecurityEventfor the affecteduserIds; force password reset on any account that showed alogin_successfrom the same IP.
Impossible travel
Login from country A while an active session exists from country B
within AUTH_IMPOSSIBLE_TRAVEL_WINDOW_HOURS fires a
impossible_travel_detected SecurityEvent + forces MFA (or blocks if
MFA is off). On repeat pattern per user: force password change + MFA
re-enrolment.
Step-up MFA — routes and behaviour
Endpoints decorated @RequireStepUp(minutes) reject callers whose
User.lastMfaAt sits outside the TTL. Frontend flow:
- Call the target endpoint → get
401 MFA_REQUIRED. - Prompt user for TOTP →
POST /auth/step-up { token }. - Retry the target endpoint.
lastMfaAt is stamped on:
- Successful TOTP verify at
/auth/login. - Explicit
/auth/step-up.
Recovery codes are accepted at /auth/step-up — mid-session
authenticator loss still has a recovery path.
Applied today to:
UsersController(class-level, 30 min)RolesController(class-level, 30 min)POST /claims/:id/disburse(15 min — higher value, tighter TTL)
Compliance checkpoints
| Checkpoint | Where evidence lives |
|---|---|
| MFA enforced on privileged roles (BOU §7.4) | Role.mfaRequired seeded via 20260715120000_auth_phase1. Query rows to prove. |
| Password rotation (BOU §7.5) | PASSWORD_MAX_AGE_DAYS_* env; enforced in AuthService.login. |
| Credential change notified (DPPA §17) | Notification templates security.password_changed/expiring/expired seeded. |
| Session hijacking defence (NIST SP 800-63B RA3.4) | RefreshToken.uaHash + countryCode; TokenService.rotate throws REFRESH_STEP_UP_REQUIRED on drift. |
| Privileged-access segmentation (BOU §7.6) | PRIVILEGED_ALLOWED_CIDRS + PrivilegedIpGuard. |
| Immutable audit trail | AuditEvent — SHA-256 hash chain; verify with POST /admin/audit/verify. |
| Breach detection | FraudAlert where subjectType='source_ip'; SecurityEvent.type='impossible_travel_detected'. |
Tabletop scenarios
Run these quarterly. Copy the ScenarioBoard template to
docs-site/docs/incident-response/YYYY-QN.md.
- JWT secret in a public GitHub gist. How fast can we rotate? Who has env access? Do we have a runbook script that lists live sessions we'd need to kill?
- Finance admin's laptop is stolen while unlocked. Do we detect the impossible-travel signal when the thief moves? Can we force-logout in under 5 min?
- Credential-stuffing from 50 IPs across 500 accounts. Does the
login_burst_across_usersrule fire per-IP? What's our WAF blocklist procedure? - DBA runs
SELECT * FROM users. How much PII leaks? What's the fallout for PDPO? Do we get an audit trail?