adr-2026-05-18-authjs-migration

Mon May 18 2026 07:00:00 GMT+0700 (Western Indonesia Time)acceptedtype/adrtopic/authtopic/migration

ADR 2026-05-18 — Migrate auth to Auth.js v5 (Google + Credentials)

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]])

Context

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:

  • No password reset (C1)
  • No email verification (C2)
  • Signup enumeration via "Email already exists" (C3)
  • API error responses leaking internals (C4)
  • No CSP header (H1), login-timing leak (H2), blacklist race (H3), PWA caching authenticated JSON (H4)

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+.

Decision

Migrate auth to Auth.js v5 with:

  • Providers: Google OAuth + Credentials (email/password).
  • Adapter: @auth/mongodb-adapter against the existing MongoDB instance.
  • Session strategy: JWT (forced for Credentials provider in v5). Sliding expiry via session.maxAge + updateAge. No JTI rotation in v1.
  • Cookies: Auth.js defaults (__Secure-authjs.session-token in prod). Drops the bespoke __Host-access_token / __Host-refresh_token scheme.
  • Account-link policy on duplicate email: auto-link on verified-email match (revised 2026-05-18 from earlier "block in v1"). When a Google OAuth sign-in matches an existing Credentials user's email, link the new OAuth account to that user — provided Google's 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).
  • Integration API auth: split off cleanly. New 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).
  • Password reset + email verification: custom on top of Auth.js (it ships neither for Credentials). Tokens hashed at rest, single-use, TTL-indexed.

Why migrate over patch

FactorPatch current systemMigrate to Auth.js
Effort to reach launch-ready~5–7 days~10–15 days
Google OAuth pathSeparate v1.1 projectBundled
CSRF + cookie defaultsManual (current is OK)Battle-tested
Refresh-family rotationStrong, customLost (revisit v1.1 if needed)
Password reset, email verifyMust buildMust still build (Credentials)
Maintenance surfaceAll customMost boilerplate offloaded
Bus-factor-of-1 riskHigherLower (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.

Consequences

  • Pros: Google OAuth out of the box; standard cookie + CSRF posture; less bespoke code to audit; future MFA / passkey providers slot in via Auth.js providers.
  • Cons: lose the rotating-refresh-family + JTI swap design (audit called this "the best part of the codebase"); add a major dependency; user model gets emailVerified + image columns from Auth.js conventions.
  • Reversible: yes — Auth.js is a thin shell over JWT + adapter; rolling back is unpleasant but possible. Data layer is unchanged.
  • Not reversible cheaply: changing the cookie scheme will invalidate any existing sessions (no public users yet — acceptable).

Scope (in)

  • Replace 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.
  • Rewrite src/lib/actions/auth.ts, src/components/auth/AuthContext.tsx, login + signup pages.
  • Extend src/lib/models/userModel.ts for Auth.js conventions.
  • Build custom password-reset + email-verify flows on top.
  • Build new integration API key system; redesign requireIntegrationUser.

Scope (out)

  • Auth.js v5 database session strategy — Credentials provider forces JWT; we accept that.
  • Refresh-family rotation in v1 — sliding session JWT is the trade.
  • Explicit "link account in settings" UI — auto-linking (above) covers the natural flow where the same email shows up via both methods. A dedicated settings-page "Link Google" / "Unlink" surface is v1.1.
  • 2FA / passkeys — backlog L2, post-v1.

Plan reference

Full implementation plan: /Users/mg/.claude/plans/i-planned-to-go-eager-biscuit.md (3 phases, ~15 days).

Backlog items:

  • [[Projects/personal-finance-notion/backlog/done/p0-authjs-phase-a-foundation|Phase A — Foundation]]
  • [[Projects/personal-finance-notion/backlog/done/p0-authjs-phase-b-features|Phase B — Features]]
  • [[Projects/personal-finance-notion/backlog/done/p0-authjs-phase-c-hardening|Phase C — Hardening + integration API]]

Realised in

  • [[Projects/personal-finance-notion/context/auth-as-built-2026-05-19]] — as-built implementation snapshot after Phase A + B + C shipped (2026-05-18 / 2026-05-19). The custom mongoose adapter (revised mid-flight from the @auth/mongodb-adapter originally specified in this ADR) is recorded there along with the cross-file wiring map and undocumented behaviors.

Addendum 2026-05-25 — Phase D: keep 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.
  • Signup / createUser / profile paths: role is not client-writable (CreateUserInputSchema omits role; updateUser strips role from $set; Mongoose adapter updateUser ignores role).
  • Session 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.
  • Proof: 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.

Related

  • [[Projects/personal-finance-notion/context/audit-2026-05-17-auth|Auth audit 2026-05-17]] — original findings
  • [[Projects/personal-finance-notion/backlog/done/p0-authjs-phase-d-pre-launch-hardening|Phase D pre-launch hardening]]
  • [[Projects/personal-finance-notion/decisions/adr-2026-05-17-stack-snapshot|ADR 2026-05-17 — Stack snapshot]] — auth section is superseded by this ADR
  • [[Projects/personal-finance-notion/personal-finance-notion|MOC]]