Authentication overview
NextForge uses NextAuth v5 with JWT sessions. Four complete flows are wired: registration, login, email verification, and password reset.
How it fits together
Authentication is split across three layers:
- lib/auth.ts - NextAuth configuration, providers, JWT and session callbacks
- actions/auth.ts - server actions for registration, login, verification, logout
- actions/email.ts - server actions for OTP delivery and password reset
The form components in components/auth/ call these server actions using useTransition - the submit button is disabled and shows a spinner for the entire duration of the action.
Registration flow
- User submits name, email, password via RegisterForm
- registerAction() sanitizes input with sanitizeAndStrip()
- Zod registerSchema validates - returns fieldErrors if invalid
- authRatelimit checks the IP - returns error if exceeded
- connectMongo() / Supabase / Firebase client initializes
- Duplicate email check - returns error if exists
- User created - password hashed by bcrypt pre-save hook (cost 12)
- generateOTP() creates 6-digit CSPRNG code
- hashOTP() hashes it with SHA-256 - OTP record saved with 10-min expiry
- sendVerificationEmail() delivers OTP via Resend
- Client redirects to /verify-email
Login flow
- User submits email, password via LoginForm
- loginAction() sanitizes and validates with loginSchema
- authRatelimit check
- signIn('credentials', ...) called - NextAuth Credentials provider runs authorize()
- User looked up by email with .select('+password')
- comparePassword() runs bcrypt.compare()
- On success: JWT minted with id, role, emailVerified - stored as HTTP-only cookie
- Client redirects to /dashboard
The JWT is never exposed to JavaScript. It lives in an HTTP-only cookie managed by NextAuth. Access session data via auth() in server components or useSession() in client components.
Email verification flow
- User submits 6-digit OTP from their email via VerifyEmailForm
- verifyEmailAction() sanitizes and validates
- OTP record queried: matching email, purpose: 'email-verification', used: false, expiresAt > now
- verifyOTP() runs crypto.timingSafeEqual - constant-time comparison
- OTP marked used - user.emailVerified set to true
- Client redirects to /dashboard
Password reset flow
- User submits email via ForgotPasswordForm
- sendPasswordResetOTPAction() - always returns the same success message regardless of whether email exists (prevents email enumeration)
- If user found: OTP generated, hashed, saved, email sent
- User submits email + OTP + new password via ResetPasswordForm
- resetPasswordAction() validates OTP, marks used, updates password (pre-save hook re-hashes)
- Client redirects to /login
Accessing the session
In server components and server actions:
app/dashboard/page.tsx
import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation'
export default async function DashboardPage() {
const session = await auth()
if (!session) redirect('/login')
return <div>Welcome, {session.user.name}</div>
}In client components:
components/nav/Navbar.tsx
'use client'
import { useSession } from 'next-auth/react'
export function Navbar() {
const { data: session, status } = useSession()
if (status === 'loading') return <NavSkeleton />
if (!session) return <GuestNav />
return <AuthNav user={session.user} />
}