nextjsboilerplate Docs
Guides

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.

ProviderEnv varsCallback URL
GoogleGOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET${NEXT_PUBLIC_APP_URL}/api/auth/callback/google
GitHubGITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET${NEXT_PUBLIC_APP_URL}/api/auth/callback/github
FacebookFACEBOOK_CLIENT_ID / FACEBOOK_CLIENT_SECRET${NEXT_PUBLIC_APP_URL}/api/auth/callback/facebook
Microsoft / Entra IDMICROSOFT_CLIENT_ID / MICROSOFT_CLIENT_SECRET${NEXT_PUBLIC_APP_URL}/api/auth/callback/microsoft-entra-id
AppleAPPLE_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 32

AUTH_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_keys table with key_prefix (visible) + key_hash (sha256, never logged).
  • /dashboard/api-keys surface — 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 :

  1. Phase 1 — Coexistence — Better Auth mounted at /api/auth/v2/*, feature-flagged at auth.use_better_auth.
  2. Phase 2 — Migration — dual-write sessions, backfill account rows, migrate password hashes (bcrypt → argon2id) on next sign-in.
  3. 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 :

  1. Append the value to organizationMemberRoleEnum in src/models/Schema.ts.
  2. Run npm run db:generate — Drizzle emits an ALTER TYPE … ADD VALUE migration.
  3. Update createInvite() (Zod role enum), the <select> in src/components/dashboard/team/InviteForm.tsx, and the role-pill mapping in /dashboard/team/page.tsx.
  4. Add role_<new> strings to DashboardTeam, AcceptInvite, and OrgSwitcher namespaces in src/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
      │                                 ▼
      │                              /dashboard

Server actions live in src/actions/invites.ts :

  • createInvite(orgId, email, role, locale) — owner / admin only. Generates a 32-char base64url token via crypto.randomBytes(24). Default expiry is +7 days.
  • revokeInvite(inviteId) — owner / admin only. Soft-revoke (revoked_at = now()).
  • acceptInvite(token) — public. Returns NEEDS_SIGNUP when 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-in and /sign-up. Default policy in dev is unlimited; production deployments set ARCJET_KEY and tighten rules in src/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.body consumption anywhere in src/actions/.

What's next

On this page