p1-auth-hardening-medium

opentype/backlogpriority/p1severity/mediumtopic/authtopic/hardening

p1 · Auth hardening (medium) — M1–M5

Status: open (M1+M3 superseded post-migration) · Severity: MEDIUM · Source: [[Projects/personal-finance-notion/context/audit-2026-05-17-auth|Auth audit 2026-05-17 §MEDIUM]]

2026-05-18 update. Under [[Projects/personal-finance-notion/decisions/adr-2026-05-18-authjs-migration|Auth.js migration]]:

  • M1 (drop legacy cookie fallbacks) — superseded; Auth.js uses its own cookie names, the legacy access_token/refresh_token/session_token reads are gone with the old src/lib/auth.ts.
  • M3 (require both JWT keys in prod) — superseded; Auth.js uses a single AUTH_SECRET. The dual-key concern is moot.
  • M2 (role field), M4 (CSRF doc), M5 (logger stringifier) — M2 + M5 done in [[Projects/personal-finance-notion/backlog/done/p0-authjs-phase-d-pre-launch-hardening|Phase D]]; M4 still applies.

Patch in the first week after launch. Group of related hardening items, none individually large enough for its own note.

M1 · Drop legacy cookie fallbacks

src/middleware.ts:17-28, src/lib/auth.ts:294-296, 303-305 read bare access_token / refresh_token / session_token as fallbacks alongside __Host- variants. A non-__Host- cookie can be planted by any subdomain; if you ever serve from *.yourdomain.com a subdomain XSS could override the legit cookie.

Fix: after the production deploy stabilises (and you're sure no live session relies on the legacy name), remove the fallback reads. Keep only __Host- names in prod, bare names in dev.

M2 · Decide on the role field

Resolved (2026-05-25): keep role for upcoming admin features; safe enforcement shipped in [[Projects/personal-finance-notion/backlog/done/p0-authjs-phase-d-pre-launch-hardening|Phase D]] (requireRole, no client-writable role, tests).

src/lib/models/userModel.ts:42-46 defines role: "admin" | "user"; Auth.js session carries it. Was unenforced pre–Phase D.

Fix (Phase D): requireRole("admin") helper + guard at least one handler + test; strip/reject role on signup/profile mutations.

M3 · Require both JWT keys in prod

src/lib/auth.ts:36-37 silently falls back JWT_REFRESH_SECRET_KEY → JWT_SECRET_KEY. In production this means one leaked key forges both token types.

Fix: add a startup check:

if (
  process.env.NODE_ENV === 'production' &&
  !process.env.JWT_REFRESH_SECRET_KEY
) {
  throw new Error('JWT_REFRESH_SECRET_KEY must be set in production');
}

Update .env.example to mandate distinct values in prod.

M4 · CSRF posture documented

src/lib/utils/csrf.ts exists but isn't enforced — defence currently rests on Next.js's built-in server-action origin check + SameSite=strict. Fine for the current surface, but if a public POST API or webhook is added, this needs revisiting.

Fix: either delete src/lib/utils/csrf.ts if it's dead code (so it doesn't mislead), or add a one-line reminder comment on top of any future /api/* POST/PUT/DELETE route.

M5 · Logger safe-stringifier

Flagged but not verified in the audit. If logger.error JSON-stringifies arbitrary error objects, MongooseError chains can have non-serializable properties and throw inside the logger.

Fix: read src/lib/utils/logger.ts; confirm it uses a safe stringifier (e.g. safe-stable-stringify) or wraps in try/catch. Quick win. Done in Phase D.

Implications

If skipped

  • M4 only remains: future contributors may assume csrf.ts is enforced or add a public POST without origin checks — fine today on server actions only, risky if webhooks or open APIs appear. M1/M2/M3/M5 are superseded or shipped.

Why this priority

  • p1 — first-week patch bucket for audit leftovers that are not launch blockers. Kept as one note because M4 is small; split to its own file if you want clearer tracking.

When shipped

  • CSRF posture is documented or dead code removed; no false sense of protection from unused utilities.

Related

  • [[Projects/personal-finance-notion/context/audit-2026-05-17-auth|Audit doc]]
  • [[Projects/personal-finance-notion/personal-finance-notion|MOC]]