Authentication overview
NextAuth credentials + 5 OAuth providers wired today, with 2FA, passkeys, API keys, and Better Auth as the additive Wave-A roadmap.
Authentication overview
The boilerplate ships with NextAuth v5 as the wired auth backbone today, plus a Better Auth migration plan documented in architecture/auth. Every flow degrades gracefully when an OAuth provider key is missing — buttons hide themselves rather than crash.
What's wired today
Credentials (email + password)
The primary sign-in transport. Implemented at src/actions/register-user.ts
and the NextAuth credentials provider. Passwords are hashed with bcrypt
(12 rounds). Users sign up at /sign-up, sign in at /sign-in.
Email verification and password reset are both live, both wired through the
shared Email library — see Email layer.
// src/actions/register-user.ts (excerpt)
const passwordHash = await bcrypt.hash(password, 12);
await db.insert(schema.users).values({
email,
passwordHash,
name,
emailVerified: null,
});
void Email.sendVerifyEmail(email, verifyUrl, locale);The void on the email call is deliberate — a Resend outage must never
block sign-up.
OAuth providers (5 wired)
All five OAuth providers below are coded and merely gated on environment
variables. Set the matching *_CLIENT_ID + *_CLIENT_SECRET in
.env.local and the provider's button appears on /sign-in.
| Provider | Env vars | Callback URL |
|---|---|---|
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET | ${NEXT_PUBLIC_APP_URL}/api/auth/callback/google | |
| GitHub | GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET | ${NEXT_PUBLIC_APP_URL}/api/auth/callback/github |
FACEBOOK_CLIENT_ID / FACEBOOK_CLIENT_SECRET | ${NEXT_PUBLIC_APP_URL}/api/auth/callback/facebook | |
| Microsoft / Entra ID | MICROSOFT_CLIENT_ID / MICROSOFT_CLIENT_SECRET | ${NEXT_PUBLIC_APP_URL}/api/auth/callback/microsoft-entra-id |
| Apple | APPLE_CLIENT_ID / APPLE_CLIENT_SECRET | ${NEXT_PUBLIC_APP_URL}/api/auth/callback/apple |
Google is the only # REQUIRED provider in .env.example because it's the
default sign-in CTA on the marketing landing. The other four are
# OPTIONAL — turn on the providers you actually plan to support.
Sessions
JWT strategy with a 30-day rolling expiry. The session secret is
AUTH_SECRET :
# Generate a real value
openssl rand -base64 32AUTH_SECRET is # REQUIRED and must be at least 32 characters. The boot
will fail loudly via Zod if it's missing or too short — this is a
deliberate visible-error contract.
Email verification + password reset
Both flows live, both call the shared Email library :
import { Email } from '@/libs/Email';
await Email.sendVerifyEmail('user@example.com', verifyUrl, 'en');
await Email.sendPasswordReset('user@example.com', resetUrl, 'fr');When RESEND_API_KEY is unset, the rendered HTML is printed to stdout
instead of dispatched — local dev works without any external account.
Account deletion (30-day grace period)
The user types their email in the dialog at
/dashboard/settings?tab=security, the action sets
users.scheduled_deletion_at = now() + 30 days, generates
users.deletion_token, and emails the cancel link. The user can sign in
any time during the 30-day window and click the cancel link to revert.
After the deadline, the daily cron process-scheduled-deletions (3 AM
UTC) hard-deletes every user past the deadline. FK
onDelete: cascade handles memberships, sessions, ledger, payments,
subscriptions, data exports.
See Compliance moat for the audit trail emitted on every deletion event.
Coming soon — additive Better Auth roadmap
Better Auth is already in package.json
(better-auth@^1.6.9) as a pre-installed but un-wired dependency. The
migration is additive — every NextAuth surface keeps working until
the cutover. Three Wave-A increments are planned :
Wave A.2 — Two-factor authentication
TOTP-based 2FA with Better Auth's twoFactor() plugin. Recovery codes
generated on enrolment, stored hashed. Enrolment surface lives at
/dashboard/settings?tab=security. Audit-chain emits
auth.2fa_enabled / auth.2fa_disabled so SOC 2 reviewers see the
on / off events.
Wave A.3 — API keys
Currently referenced in API Reference under "Bearer API key"
but not yet wired to a real api_keys table. Wave A.3 adds :
api_keystable withkey_prefix(visible) +key_hash(sha256, never logged)./dashboard/api-keyssurface — create / list / revoke. Secret displayed exactly once at creation.- Server-side middleware that resolves
Authorization: Bearer ak_…to a user + org, with last-used tracking. - Per-tier seat limits (Free → 1 key, Pro → 10, Scale → unlimited) wired
through the existing
<TierGate>component — see Billing. - Audit-chain emits
api_key.created/api_key.revoked.
Wave A.4 — Passkeys (WebAuthn)
Better Auth's passkey() plugin. Users register a passkey from
/dashboard/settings?tab=security and use it as a passwordless sign-in
factor. Backed by the webauthn_credentials table. Apple / Google
password manager autofill works out of the box on supported browsers.
Migration sequencing
The full migration plan lives in architecture/auth. The TL;DR :
- Phase 1 — Coexistence — Better Auth mounted at
/api/auth/v2/*, feature-flagged atauth.use_better_auth. - Phase 2 — Migration — dual-write sessions, backfill account rows, migrate password hashes (bcrypt → argon2id) on next sign-in.
- Phase 3 — Cutover — flip the flag to 100 %, drop NextAuth route handlers (structural-only commit per Tidy First).
Multi-tenant ergonomics
The boilerplate is multi-tenant out of the box. Every user can belong to
multiple organizations with role owner, admin, or member (Postgres
enum organization_member_role).
getCurrentOrg() resolves the active organization from the session — see
the helper at src/libs/getCurrentOrg.ts. The dashboard <OrgSwitcher>
in the sidebar lets the user switch active org without re-signing-in.
Add a new role :
- Append the value to
organizationMemberRoleEnuminsrc/models/Schema.ts. - Run
npm run db:generate— Drizzle emits anALTER TYPE … ADD VALUEmigration. - Update
createInvite()(Zod role enum), the<select>insrc/components/dashboard/team/InviteForm.tsx, and the role-pill mapping in/dashboard/team/page.tsx. - Add
role_<new>strings toDashboardTeam,AcceptInvite, andOrgSwitchernamespaces insrc/locales/{en,fr}.json.
Invites
Owners and admins invite teammates by email at
/dashboard/team?tab=invites. The flow :
[Owner/admin] [Invitee]
│ │
│ /dashboard/team?tab=invites │
│ InviteForm ─► createInvite() │
│ │ insert row, │
│ │ random token │
│ │ sendInvite ──► invite email
│ │
│ │ click link
│ ▼
│ /accept-invite/[token]
│ │
│ signed in?── yes ──► acceptInvite() ─► /dashboard
│ │ no
│ ▼
│ /sign-up?invite=<token>
│ │
│ registerUser() auto-accepts
│ ▼
│ /dashboardServer actions live in src/actions/invites.ts :
createInvite(orgId, email, role, locale)— owner / admin only. Generates a 32-char base64url token viacrypto.randomBytes(24). Default expiry is+7 days.revokeInvite(inviteId)— owner / admin only. Soft-revoke (revoked_at = now()).acceptInvite(token)— public. ReturnsNEEDS_SIGNUPwhen the user is not signed in, so the caller can redirect to/sign-up?invite=token.lookupInvite(token)— read-only state machine for the accept page.
Defence in depth
- CSRF — NextAuth handles CSRF on credential submissions automatically.
- Brute force — Arcjet middleware throttles
/sign-inand/sign-up. Default policy in dev is unlimited; production deployments setARCJET_KEYand tighten rules insrc/middleware.ts. - Session fixation — JWT regenerated on every sign-in.
- Mass-assignment — every server action runs Zod validation at the
entry point. There is no bare
req.bodyconsumption anywhere insrc/actions/.
What's next
- Billing & subscriptions — once the user is signed in, wire payments.
- Compliance moat — every auth event hits
audit_log, security-grade events also hit the tamper-evidentaudit_chain. - architecture/auth — the deeper "why NextAuth → Better Auth" walkthrough.
Initial setup
Clone, configure, migrate, seed, run. The reproducible path from a bare checkout to a fully wired SaaS skeleton with audit chain, billing, and demo data.
Billing & subscriptions
Stripe setup, the three recurring tiers, checkout flow, customer portal, webhook contract, and how the audit chain captures every lifecycle event.