Skip to main content

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

EnvPurposeSet-once vs rotatableNotes
JWT_KEYSJSON array of {kid, alg, active, secret | publicKey | privateKey}. Active key signs; every key verifies.RotatableRS256 recommended — see rotation procedure below.
JWT_ACCESS_SECRETLegacy HS256 fallbackDeprecatedLoaded as synthetic kid="legacy" when JWT_KEYS is unset.
SECRETS_KEYAES-256-GCM key for SecretsCipher (integration secrets, webhook keys).Long-livedRotating invalidates existing ciphertext; needs a data-migration.
PII_HASH_KEYHMAC-SHA256 key for PiiCipher deterministic hashes.Never rotateRotating invalidates every hash — breaks phone-hash lookups, dedup.
MFA_KEYAES-256-GCM key for MFA seed encryption.Long-livedRotating invalidates existing enrolments — users re-enrol.
MFA_HASH_KEYHMAC-SHA256 key for MFA recovery-code hashes.Long-livedRotating invalidates existing recovery codes — users regenerate.
CLAIM_PIN_HMAC_KEYHMAC-SHA256 key for public-claim-lookup PIN.Long-livedRotating invalidates existing claim PINs.
PRIVILEGED_ALLOWED_CIDRSComma-separated CIDR list for privileged JWT routes.RotatableEmpty = no-op (dev). Set to office + VPN egress for prod.
GEOIP_LOOKUP_URL + GEOIP_LOOKUP_KEYMerchant-controlled geo-IP endpoint.RotatableEmpty = country drift detection disabled.
HIBP_ENABLED=trueEnable Have-I-Been-Pwned range check on password set.RotatableEgress to api.pwnedpasswords.com. Opt-in.
PASSWORD_MAX_AGE_DAYS_ADMIN=90Password expiry for privileged roles.RotatableDefault 90.
PASSWORD_MAX_AGE_DAYS_USER=180Password expiry for standard roles.RotatableDefault 180.
AUTH_IMPOSSIBLE_TRAVEL_WINDOW_HOURS=4Cross-country login window.RotatableDefault 4h.
CLAIM_ACK_SLA_DAYS=14 / CLAIM_DECISION_SLA_DAYS=30Insurance Act 2017 §88–95 SLAs.RotatableChange 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.

  1. Generate the new keypair (RS256 example):

    openssl genrsa -out new.pem 2048
    openssl rsa -in new.pem -pubout -out new.pub
  2. 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": "..." }
    ]
  3. Deploy and restart. All access tokens still signed with the old key (prod-2026-a), but the new key is loaded and can verify.

  4. Flip active on the new key, redeploy. New tokens now signed with prod-2026-b; old tokens still verified by prod-2026-a.

  5. Wait one access-token TTL (default 15 min) so every in-flight old token expires naturally.

  6. Remove the old key from JWT_KEYS, redeploy. Old tokens now fail verification with TOKEN_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

  1. Trigger key rotation (procedure above), skipping the 15-min wait.
  2. Query security_events for the last 24 h of login_success events from unusual IPs / countries — sample any that don't match usual patterns and force-logout via POST /auth/logout-all on the affected userId.
  3. Rotate the SECRETS_KEY if the leaked value was JWT_ACCESS_SECRET in fallback mode — same key means integration secrets are also compromised.

Compromised admin account

  1. Suspend the account: PATCH /users/:id { status: 'suspended' }.
  2. POST /users/:id/roles DELETE of every privileged role.
  3. POST /auth/logout-all (impersonated) — nukes every refresh-token family for the user.
  4. Audit trail: query AuditEvent where actorId = compromised-user, createdAt > when compromise started. Every mutation is on the hash-chained trail — file a rollback plan.
  5. Rotate any secrets the account could have exfiltrated (SecretsCipher payloads 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:

  1. Block the IP at the edge (WAF / nginx).
  2. Query SecurityEvent for the affected userIds; force password reset on any account that showed a login_success from 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:

  1. Call the target endpoint → get 401 MFA_REQUIRED.
  2. Prompt user for TOTP → POST /auth/step-up { token }.
  3. 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

CheckpointWhere 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 trailAuditEvent — SHA-256 hash chain; verify with POST /admin/audit/verify.
Breach detectionFraudAlert 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.

  1. 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?
  2. 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?
  3. Credential-stuffing from 50 IPs across 500 accounts. Does the login_burst_across_users rule fire per-IP? What's our WAF blocklist procedure?
  4. DBA runs SELECT * FROM users. How much PII leaks? What's the fallout for PDPO? Do we get an audit trail?