2026-05-18 update: Direction shifted — auth is being migrated to Auth.js v5 (Google + Credentials) rather than continuing to patch the bespoke JWT system. Launch slips ~2 weeks to W23+. See [[Projects/personal-finance-notion/decisions/adr-2026-05-18-authjs-migration|ADR 2026-05-18 — Migrate to Auth.js v5]]. Findings remap:
- C1 / C2 / C3 → Phase B of migration (custom flows on top of Auth.js Credentials)
- C4 → Done 2026-05-18 (commit pending)
- H3 → Done 2026-05-18 (commit pending); moot under Auth.js
- H4 → Done 2026-05-18 (commit pending)
- H1 / H2 → Phase C of migration
- M1 / M3 → Superseded by Auth.js (legacy cookie names + dual JWT secrets go away)
- M2 / M4 / M5 → Still apply, addressed in Phase C
- L1 → Elevated; Phase C ships per-user API keys for the integration API
Status: 4 CRITICAL + 4 HIGH findings before public multi-user launch next week. Core JWT design is strong; gaps are around account lifecycle (no password reset, no email verification) and information leakage (enumeration, error responses).
Shipping to public production next week with open signups. Financial app — token compromise = direct access to bank/transaction data, so the bar is higher than a typical CRUD product. This document is a point-in-time audit; fixes are not implemented here — see the Pre-launch sprint below to triage.
Scope confirmed up front:
INTEGRATION_API_TOKEN_PREFIX unset → routes return 503). Concern noted but not blocking.Overall posture: strong core, with a small number of production-blocking gaps.
The JWT design is the best part of this codebase: rotating refresh families with JTI swap (src/lib/auth.ts:103-121, src/lib/auth.ts:365-434), blacklist with TTL cleanup (src/lib/utils/tokenBlacklist.ts), __Host- cookies in production (src/lib/auth-cookies.ts:7-8), 15m/7d expiry split, bcryptjs (10 rounds), per-email rate limiting with account lockout. Edge middleware does signature-only checks and defers revocation to server (src/middleware.ts:51-57). Well-thought-out work.
Blocking a public launch:
victim@example.com, get a session, and own the account before the real user does. For a finance app this is reputation-ending.src/lib/actions/user.ts:125 throws "Email already exists", enabling account enumeration.src/lib/apiHelper.ts:86-93 returns raw error.message to client and console.errors the full object.The rest is hardening (CSP, login timing-equalisation, integration token redesign for later, etc.) — important but not gating.
Severity scale: CRITICAL = block launch · HIGH = strongly recommend before launch · MEDIUM = ship a patch in the first week · LOW = backlog.
grep -r "forgot.?password\|reset.?password\|verify.?email" returns nothing across src/.src/lib/actions/user.ts:197 makes this worse). Support load + churn.src/lib/actions/auth.ts:155-165 — signup immediately calls createTokenPair and setTokenCookies. No EmailVerificationToken model, no verification gate anywhere.victim@example.com before the real victim. Victim later tries to sign up, hits the C3 enumeration error, and can never claim their own address. Worst-case impersonation for a finance app.emailVerified: boolean to userModel.ts. Issue an EmailVerificationToken (random 32-byte, hashed at rest, TTL 24h, one-time use). Gate loginAction (and ideally the immediate post-signup token issuance) on emailVerified === true. Reuse the same Resend/SES/SMTP transport as password reset.src/lib/actions/user.ts:123-126:
const existingUser = await User.findOne({ email: email.toLowerCase() });
if (existingUser) {
throw new Error("Email already exists");
}
Error bubbles back to the client via src/lib/actions/auth.ts:181-189.src/lib/apiHelper.ts:85-93:
} catch (error) {
console.error("API Error:", error);
...
const response = createErrorResponse(
error instanceof Error ? error : "An unexpected error occurred",
code
);
return NextResponse.json(response, { status: response.code });
}
createErrorResponse (line 29-40) returns the raw error.message in JSON.Error("Failed to update user settings") strings, etc. surface to clients. console.error puts full stack traces in Vercel logs — fine for ops, but combined with the JSON leak it's a bad pattern.apiHelper.ts:
code >= 500 ? "Internal server error" : error.message. Keep 4xx detail (validation messages are useful).console.error with the existing logger.error (already used in src/lib/actions/*).src/middleware.ts:84-94 sets X-Content-Type-Options, X-Frame-Options: DENY, X-XSS-Protection, Referrer-Policy, and Strict-Transport-Security in prod. No CSP.next.config.mjs async headers() (not currently used) or in middleware. Tight starting policy:
default-src 'self';
script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' data: blob:;
connect-src 'self' https://openrouter.ai;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
Run report-only first (Content-Security-Policy-Report-Only) for 24h, watch the console, then enforce.src/lib/actions/user.ts:153-216. If the email doesn't exist, function returns at line 168 (no bcrypt, no DB write). If the email exists but the password is wrong, code runs bcrypt.compare (line 191), then user.save() (line 210). The two branches differ by tens of milliseconds.bcrypt.compare against a dummy hash when the user doesn't exist. Precompute one constant (e.g. const DUMMY_HASH = await bcrypt.hash("dummy", 10)). Compare against it in the not-found branch.src/lib/auth.ts:346-352 — when access token is near expiry, current token is added to blacklist and a new one is issued in the same handler. Concurrent requests from the same user can race into tokenBlacklist.add(accessToken, ...), and the in-flight request then returns "blacklisted".src/lib/actions/auth.ts:208-211). Remove line 348 in auth.ts.next.config.mjs:24-37 — api-reads runtimeCache with StaleWhileRevalidate for any same-origin GET /api/*. Service Worker cache is per-origin, not per-user./api/* from the SW cache entirely — they're cheap to refetch; offline-fresh financial data is dubious anyway; or (b) clear the api-reads cache from logoutAction via a caches.delete("api-reads") postMessage to the SW. (a) is simpler.src/middleware.ts:17-28, src/lib/auth.ts:294-296, 303-305 read access_token / refresh_token / session_token (non-__Host-) as fallbacks. A non-__Host- cookie can be set by any subdomain — if you ever serve from *.yourdomain.com, a subdomain XSS could plant a cookie this code would honor over the legitimate one.__Host- names in prod, bare names in dev.src/lib/models/userModel.ts:42-46 defines role: "admin" | "user", propagated through UserDataSchema in src/lib/auth.ts:47. grep for role.*admin\|isAdmin\|requireAdmin returns nothing.requireRole("admin") helper plus an integration test asserting a user cannot call an admin-only route.JWT_REFRESH_SECRET_KEY silently falls back to JWT_SECRET_KEYsrc/lib/auth.ts:36-37 — fine for dev, but in production this means an access-token forgery primitive (if you ever leak the access key) is also a refresh-token forgery primitive.if (process.env.NODE_ENV === "production" && !process.env.JWT_REFRESH_SECRET_KEY) {
throw new Error("JWT_REFRESH_SECRET_KEY must be set in production");
}
Document in .env.example that the two MUST differ in prod.src/lib/utils/csrf.ts exists but is documented as not enforced. Server actions rely on Next.js's built-in origin check + SameSite=strict cookies./api/* POST/PUT/DELETE route. Or delete src/lib/utils/csrf.ts if it's dead code so it doesn't mislead.logger.error JSON-stringifies arbitrary error objects, MongooseError / native Error chains can have non-serializable properties.src/lib/utils/logger.ts and confirm it has a safe stringifier.src/lib/integrations/requireIntegrationUser.ts:30-49 uses Bearer = PREFIX + User._id (hex). Mongo _id is not a secret (timestamp + machine + counter, partially predictable; can leak via API responses if _id is ever serialised). Today the route returns 503 because INTEGRATION_API_TOKEN_PREFIX is unset. Before re-enabling, replace with per-user random API keys (32 bytes), hashed at rest, listed/revoked from a settings page, scoped, with explicit expiry. Add an integrationKeys collection.User.lastLogin is set, but no per-event log. Useful for "recent sessions" UI and forensics.OPENROUTER_API_KEY is server-side only (good). Confirm it's not in any NEXT_PUBLIC_* envs at launch.package.json build runs next build only; playwright.yml runs only e2e. Add a lint && tsc --noEmit step.npm audit / dependabot in CI. Add npm audit --production to the workflow.src/lib/auth.ts:103-121 (issue), :365-434 (rotate). Reuse of a stale refresh JTI revokes the entire family.src/lib/utils/tokenBlacklist.ts + MongoDB TTL index.__Host- cookie prefix in production: src/lib/auth-cookies.ts:7-8. Prevents subdomain cookie injection.httpOnly: true, secure: isProd, sameSite: "strict" (auth-cookies.ts:25-30).src/middleware.ts:51-57. Full revocation checks deferred to Node getUserData(). bcryptjs wouldn't run on edge anyway.src/lib/actions/user.ts:194-208.src/lib/actions/auth.ts:67-74, 135-141.getUserData() server check. A bypass of one doesn't bypass the other.Required for launch. Minimal viable design.
Schema — new file src/lib/models/passwordResetTokenModel.ts:
{
userId: string; // FK to User.userId
tokenHash: string; // sha256(token) — NEVER store raw
expiresAt: Date; // now + 1h, TTL-indexed
usedAt: Date | null; // single-use enforcement
createdAt: Date; // for audit + rate limiting
}
TTL index on expiresAt. Compound index on userId + createdAt for rate-limit lookups.
Server actions — new file src/lib/actions/passwordReset.ts:
requestPasswordReset(email):
rateLimiter utility.crypto.randomBytes(32).toString("base64url"), store sha256 hash, send email with https://<host>/reset-password?token=<raw>.resetPassword(token, newPassword):
newPassword via existing passwordValidation (src/lib/validations/common.ts).expiresAt > now && usedAt === null.userModel.ts:96-106 hashes it).usedAt = now.revokeRefreshFamilyByFamilyId in a loop, or add a revokeAllRefreshFamiliesForUser(userId) helper). Clear current cookies./login with a flash.UI — two new routes added to the public allowlist in src/lib/auth-routes.ts:
/forgot-password — email input, calls requestPasswordReset, shows "if an account exists, we've sent a link" success state regardless./reset-password?token=... — new password + confirm, calls resetPassword.Email transport — pick one and add to .env.example:
Implementation audit checklist:
usedAt.Day-by-day, ordered by dependency:
Day 1 — Stop the bleeding (one-liners / pure deletions):
src/lib/actions/user.ts:125 with the "send already-exists email + return identical success" path.src/lib/apiHelper.ts:85-93 to gate 5xx detail behind NODE_ENV !== "production".src/lib/auth.ts:347-349 (no blacklist on auto-rotate)./api/* — remove or guard the api-reads block in next.config.mjs.Day 2-3 — Email infrastructure:
RESEND_API_KEY, MAIL_FROM to .env.example + startup check.sendTransactional(template, to, data) wrapper.Day 3-4 — Password reset: implement per design above. End-to-end Playwright test.
Day 4-5 — Email verification: add emailVerified to userModel.ts, issue token on signup, gate login on verified, add /verify-email?token= route. Backfill: existing users (if any) start verified.
Day 5 — Hardening:
Day 6 — Verification: full pre-launch checklist (below) on a staging deploy with prod env vars.
Day 7 — Buffer / launch.
Run on a staging deploy that mirrors prod (real cookie flags, real HTTPS, real env vars).
__Host- prefix, Secure, HttpOnly, SameSite=Strict./login./login → redirect to /.__Host-access_token value, log out, paste it back via DevTools → protected route should still 401 (token is blacklisted).POST /signup with an existing email → response identical to a new email signup.POST /login with nonexistent@example.com vs an existing email + wrong password → time both with curl -w "%{time_total}\n" 20× each. Means should match within ~5ms after H2.loginAction 6× wrong password → 6th attempt rate-limited.loginAction 6× same email → account locked for 30 min.curl -I https://<staging>/ shows:
Strict-Transport-Security: max-age=31536000; includeSubDomainsX-Frame-Options: DENYX-Content-Type-Options: nosniffContent-Security-Policy: ... (after H1)JWT_SECRET_KEY="" → app crashes at boot (it currently does — preserve this).NODE_ENV=production without JWT_REFRESH_SECRET_KEY after M3 → crashes at boot.GET /api/integrations/v1/bank-accounts with no auth + INTEGRATION_API_TOKEN_PREFIX unset → 503.npm run test:e2e passes.npm run lint && npx tsc --noEmit to CI before next build (L6).npm audit --production clean.src/lib/actions/user.ts — C3 (signup enumeration), H2 (login timing)src/lib/actions/auth.ts — wire in email verification gate (C2), password reset actionssrc/lib/apiHelper.ts — C4 (error leakage)src/lib/auth.ts — H3 (drop blacklist-on-rotate), M3 (require both JWT keys in prod)src/lib/auth-routes.ts — add /forgot-password, /reset-password, /verify-email to public pathssrc/middleware.ts — H1 (CSP), M1 (drop legacy cookie reads)src/lib/models/userModel.ts — add emailVerified (C2)src/lib/models/passwordResetTokenModel.ts — new, password resetsrc/lib/models/emailVerificationTokenModel.ts — new, verificationsrc/lib/actions/passwordReset.ts — newsrc/lib/email/* — new, transactional email transportnext.config.mjs — H4 (drop API GET caching) or CSP via headers().env.example — document JWT_REFRESH_SECRET_KEY requirement in prod, add mail provider keysgitleaks or trufflehog separately).npm audit --production and osv-scanner).