Status: accepted (supersedes the rolled-their-own JWT section of [[Projects/personal-finance-notion/decisions/adr-2026-05-17-stack-snapshot|ADR 2026-05-17 — Stack snapshot]])
The 2026-05-17 audit ([[Projects/personal-finance-notion/context/audit-2026-05-17-auth|audit doc]]) found 4 CRITICAL and 4 HIGH gaps in the bespoke JWT auth ahead of a planned W21 public launch:
"Email already exists" (C3)Day-1 quick wins (C3 mask, C4 sanitisation, H3 race-fix, H4 PWA cache removal) landed 2026-05-18 in the working tree. The remaining critical work — password reset + email verification — would need email infrastructure (Resend/SES/SMTP), token models, and 4–5 days of careful build.
Given that work had to happen anyway, and that the current launch surface is single-stack email/password with no OAuth, the user decided to fold it into a full migration to Auth.js v5 (next-auth@5) with Google OAuth + Credentials providers. Launch slips to W23+.
Migrate auth to Auth.js v5 with:
@auth/mongodb-adapter against the existing MongoDB instance.session.maxAge + updateAge. No JTI rotation in v1.__Secure-authjs.session-token in prod). Drops the bespoke __Host-access_token / __Host-refresh_token scheme.profile.email_verified === true. Same in reverse for the (rare) case of a Credentials signup matching an existing OAuth-only user. Implemented via allowDangerousEmailAccountLinking: true on the Google provider + an email_verified guard in the signIn callback. Refused only when the OAuth provider didn't verify the email (effectively never for Google personal accounts; occasional Workspace setups with weak verification get bounced with a friendly message).integrationKeys collection with per-user random 32-byte keys, hashed at rest, scoped, expirable. No more shared JWT with user auth (was L1; now Phase C of migration).| Factor | Patch current system | Migrate to Auth.js |
|---|---|---|
| Effort to reach launch-ready | ~5–7 days | ~10–15 days |
| Google OAuth path | Separate v1.1 project | Bundled |
| CSRF + cookie defaults | Manual (current is OK) | Battle-tested |
| Refresh-family rotation | Strong, custom | Lost (revisit v1.1 if needed) |
| Password reset, email verify | Must build | Must still build (Credentials) |
| Maintenance surface | All custom | Most boilerplate offloaded |
| Bus-factor-of-1 risk | Higher | Lower (community-maintained core) |
The Auth.js win is narrower than it appears — password reset + email verification still need custom work — but the bundled Google OAuth + cookie/CSRF defaults + reduced maintenance surface are worth the extra ~5 days for a solo maintainer.
emailVerified + image columns from Auth.js conventions.src/lib/auth.ts, src/lib/auth-cookies.ts, src/lib/auth-edge.ts, src/middleware.ts, src/lib/utils/tokenBlacklist.ts, src/lib/utils/refreshTokenSession.ts.src/lib/actions/auth.ts, src/components/auth/AuthContext.tsx, login + signup pages.src/lib/models/userModel.ts for Auth.js conventions.requireIntegrationUser.Full implementation plan: /Users/mg/.claude/plans/i-planned-to-go-eager-biscuit.md (3 phases, ~15 days).
Backlog items:
@auth/mongodb-adapter originally specified in this ADR) is recorded there along with the cross-file wiring map and undocumented behaviors.role, enforce safely (M2)Decision: Retain role: "admin" | "user" on userModel and in the Auth.js JWT/session (auth.config.ts module augmentation unchanged). Admin product features ship post-launch; Phase D only removes privilege-escalation footguns.
Enforcement (shipped in app repo):
requireRole("admin") in src/lib/auth/requireRole.ts — fail closed (401 unauthenticated, 403 wrong role) for server actions and API routes.createUser / profile paths: role is not client-writable (CreateUserInputSchema omits role; updateUser strips role from $set; Mongoose adapter updateUser ignores role).role is set from DB at sign-in only (authorize() return value and adapter reads); jwt trigger === "update" does not accept role from the client.adminProbe server action + GET /api/admin/probe + Vitest coverage in requireRole.test.ts.Also in Phase D (same backlog): H2 login timing equalization — DUMMY_HASH + bcrypt.compare on user-not-found and inactive paths in src/auth.ts authorize().
Out of scope: admin dashboards, user-management UI, in-app role assignment — track when admin surface is prioritized.