Authentication
NextAuth v5 JWT authentication with credentials, Google, and GitHub providers. Zero database queries on navigation.
LaunchFst uses NextAuth v5 with a pure JWT strategy. There is no PrismaAdapter — the JWT callback handles all database interactions at sign-in time, and the session callback reads exclusively from the token. This means zero database queries on every page navigation.
Providers
Three providers are supported out of the box.
Credentials (always active)
Email and password authentication using bcryptjs (cost factor 12). The authorize() function looks up the user by email, verifies the password hash with compare(), and returns the user object on success.
Google (conditional)
Enabled when all three conditions are met: NEXT_PUBLIC_GOOGLE_ENABLED="true", GOOGLE_CLIENT_ID, and GOOGLE_CLIENT_SECRET are set. The Google button only renders when all three are present.
GitHub (conditional)
Enabled when NEXT_PUBLIC_GITHUB_ENABLED="true", GITHUB_CLIENT_ID, and GITHUB_CLIENT_SECRET are all set.
# .env.local
NEXT_PUBLIC_GOOGLE_ENABLED="true"
GOOGLE_CLIENT_ID="your-client-id"
GOOGLE_CLIENT_SECRET="your-client-secret"
NEXT_PUBLIC_GITHUB_ENABLED="true"
GITHUB_CLIENT_ID="your-client-id"
GITHUB_CLIENT_SECRET="your-client-secret"For OAuth providers, the user is automatically created on first sign-in and their OAuth account is linked via the Account model.
JWT Callback — Core Logic
The JWT callback in lib/auth.ts runs in two scenarios.
On sign-in (user object present)
- OAuth (Google/GitHub): Finds or creates the user in the database, upserts the
Accountrecord for the OAuth link, then populates the token with all user fields. - Credentials: Looks up the user by email and populates the token.
All fields stored in the token:
token.id = dbUser.id
token.name = dbUser.name
token.email = dbUser.email
token.image = dbUser.image
token.role = dbUser.role
token.plan = dbUser.plan
token.subscriptionStatus = dbUser.subscriptionStatus
token.onboardingDone = dbUser.onboardingDone
token.activeOrgId = dbUser.activeOrgId
token.lastVerified = Date.now()On subsequent requests (no user object)
No database query — reads from the existing token.
Revalidation schedule:
- Every 15 minutes: full DB refresh (role, plan, subscription, onboarding status)
- Every 60 seconds: lightweight check on
User.updatedAt— if the record was updated, a full refresh happens immediately (catches profile edits like name/avatar) - If
onboardingDone === false: revalidates on every request until complete
Session callback
Reads only from the token. Zero database queries guaranteed.
// lib/auth.ts — session callback
async session({ session, token }) {
session.user.id = token.id as string
session.user.role = (token.role as string) ?? "USER"
session.user.plan = (token.plan as string) ?? "FREE"
session.user.subscriptionStatus = (token.subscriptionStatus as string) ?? "INACTIVE"
session.user.onboardingDone = (token.onboardingDone as boolean) ?? false
session.user.activeOrgId = (token.activeOrgId as string) ?? null
return session
}Session Shape
session.user: {
id: string
email: string
name: string | null
image: string | null
role: "USER" | "ADMIN" | "SUPERADMIN"
plan: "FREE" | "STARTER" | "PRO" | "ENTERPRISE"
subscriptionStatus: "INACTIVE" | "ACTIVE" | "TRIALING" | "PAST_DUE" | "CANCELLED" | "PAUSED"
onboardingDone: boolean
activeOrgId: string | null
}Auth Helpers
All helpers live in lib/auth-utils.ts and are wrapped with React.cache() — they deduplicate within a single server render.
getRequiredSession
Use in any authenticated server component or API route. Redirects to /login if no valid session.
import { getRequiredSession } from "@/lib/auth-utils"
export default async function DashboardPage() {
const session = await getRequiredSession()
// session.user is guaranteed here
return <Dashboard user={session.user} />
}getAdminSession
Requires role === "ADMIN". Redirects SUPERADMIN to /dashboard/superadmin, regular users to /dashboard.
getSuperAdminSession
Requires role === "SUPERADMIN". Redirects ADMIN to /dashboard/admin, regular users to /dashboard.
getCurrentUser
Returns the session user or null — no redirect. Use on pages accessible to both authenticated and unauthenticated users.
redirectIfAuthenticated
Use on public pages (login, signup) to send already-authenticated users to /dashboard.
getVerifiedUser
Like getRequiredSession, but also confirms the user still exists in the database via a DB query. Use for sensitive operations like account deletion.
middleware.ts for auth checks. LaunchFst intentionally avoids middleware — use the session helpers above in your server components and API routes.Protecting API Routes
// app/api/example/route.ts
import { getRequiredSession } from "@/lib/auth-utils"
import { NextResponse } from "next/server"
export async function GET() {
const session = await getRequiredSession()
return NextResponse.json({ success: true, data: { userId: session.user.id } })
}
// Admin-only route
export async function DELETE() {
const session = await getAdminSession()
// only ADMIN reaches here
}Signup Flow
POST /api/auth/signup — creates a new user with email + password.
Validation (Zod):
name: 2–50 charactersemail: valid email formatpassword: minimum 8 characters
On success:
- Password hashed with bcryptjs (cost 12)
- User created in database
- Welcome notification created (non-blocking)
- Welcome email sent (non-blocking, only if email is configured)
If features.comingSoon === true, signup returns 403 Forbidden.
Password Reset Flow
Request reset
POST /api/auth/forgot-password with { email }.
A crypto.randomBytes(32) token is generated, its SHA-256 hash is stored in User.resetToken, and expiry is set to 1 hour in the future. The raw token is emailed to the user.
Returns success even if the email doesn't exist (prevents email enumeration).
Reset password
POST /api/auth/reset-password with { token, email, password }.
Verifies the raw token against the stored hash and checks it hasn't expired, then updates the password hash.
The reset link format: https://yourdomain.com/reset-password?token=<raw>&email=<encoded>
Secret Configuration
The auth secret is read from AUTH_SECRET (not NEXTAUTH_SECRET):
# Generate with:
openssl rand -base64 32
# Add to .env.local:
AUTH_SECRET="your-generated-secret"In development, a placeholder secret is used automatically if AUTH_SECRET is not set. In production, it must be set explicitly.