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 Account record 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.

Do not use 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 characters
  • email: valid email format
  • password: minimum 8 characters

On success:

  1. Password hashed with bcryptjs (cost 12)
  2. User created in database
  3. Welcome notification created (non-blocking)
  4. 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.

Demo Mode — Explore freely. Some actions are restricted. demo@launchfst.dev / demo1234

Get LaunchFst →