How to Add Authentication to Any React App in 2026
TL;DR
Use Clerk for the fastest production auth; Auth.js (NextAuth v5) for Next.js self-hosted; Lucia for custom self-hosted. Clerk handles email, social login, MFA, session management, and UI components in one SaaS. Auth.js is free but you manage the database. Lucia is pure TypeScript with zero opinions — build exactly what you need. The right choice depends on how much control vs convenience you need.
Key Takeaways
- Clerk: fastest to production — pre-built UI, session management, MFA, all included
- Auth.js v5: Next.js native — free self-hosted, database sessions, works with any DB
- Lucia: maximum control — no magic, build auth your way, TypeScript-first
- JWT vs sessions: sessions (DB-backed) are safer; JWT fine for stateless APIs
- Always use HTTPS, HttpOnly cookies, and CSRF protection in production
Choose Your Approach
The first decision in any auth implementation is which option matches your requirements. Here is a practical decision tree:
Need auth working in under an hour? → Use Clerk. It handles email/password, social OAuth, magic links, passkeys, MFA, and session management with pre-built UI components. You write about 10 lines of code.
Building with Next.js and want full control over the database without a paid service? → Use Auth.js v5 (formerly NextAuth). It integrates deeply with Next.js App Router, works with any database via adapters, and handles the OAuth dance for 40+ providers.
Need to implement custom auth logic, or want to understand exactly what is happening? → Use Lucia. It is a session management library (not a full auth framework) that gives you the primitives to build auth exactly how you want it, with full TypeScript types throughout.
Building a purely server-side API with no browser clients? → JWT + manual validation is reasonable. Stateless tokens work well for machine-to-machine auth and mobile apps.
Never roll your own auth for production browser applications. The failure modes — insecure session tokens, CSRF vulnerabilities, timing attacks on password comparison, insufficient key stretching — are not obvious to detect and can be catastrophic.
Option 1: Clerk (Managed Auth)
Clerk is the fastest path to production auth. It manages everything: session tokens, device fingerprinting, anomaly detection, and compliance (SOC 2, GDPR). The free tier covers up to 10,000 monthly active users, which is enough for most early-stage applications.
npm install @clerk/nextjs # Next.js App Router
# or
npm install @clerk/react # React SPA (non-Next.js)
Set environment variables:
# .env.local
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/dashboard
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/dashboard
Wrap your app in ClerkProvider:
// app/layout.tsx
import { ClerkProvider } from '@clerk/nextjs';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<ClerkProvider>
<html lang="en">
<body>{children}</body>
</html>
</ClerkProvider>
);
}
Add route protection with middleware:
// middleware.ts — protect routes
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
const isProtectedRoute = createRouteMatcher([
'/dashboard(.*)',
'/settings(.*)',
'/api/protected(.*)',
]);
export default clerkMiddleware((auth, req) => {
if (isProtectedRoute(req)) auth().protect();
});
export const config = {
matcher: ['/((?!.*\\..*|_next).*)', '/', '/(api|trpc)(.*)'],
};
Use Clerk components and hooks in your UI:
import { useUser, SignInButton, UserButton } from '@clerk/nextjs';
function Navbar() {
const { isSignedIn, user } = useUser();
return (
<nav>
{isSignedIn ? (
<>
<span>Hello, {user.firstName}</span>
<UserButton afterSignOutUrl="/" />
</>
) : (
<SignInButton />
)}
</nav>
);
}
Access the session in Server Components:
// app/dashboard/page.tsx — Server Component
import { auth, currentUser } from '@clerk/nextjs/server';
import { redirect } from 'next/navigation';
export default async function Dashboard() {
const { userId } = auth();
if (!userId) redirect('/sign-in');
const user = await currentUser();
return <div>Welcome, {user?.firstName}</div>;
}
Clerk also provides pre-built sign-in and sign-up pages. Create these route files and they render a complete, styled auth flow:
app/sign-in/[[...sign-in]]/page.tsx → <SignIn />
app/sign-up/[[...sign-up]]/page.tsx → <SignUp />
The complete setup takes about 10 minutes and you get email/password, Google, GitHub, MFA, and email verification working out of the box.
Option 2: Auth.js v5 (NextAuth)
Auth.js v5 is a rewrite of NextAuth designed for Next.js App Router. It uses Next.js Server Actions and middleware natively, supports database sessions via Prisma or Drizzle adapters, and is free to self-host.
npm install next-auth@beta
Create the core auth configuration:
// auth.ts — at project root
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
import Google from 'next-auth/providers/google';
import Credentials from 'next-auth/providers/credentials';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { prisma } from '@/lib/prisma';
import bcrypt from 'bcryptjs';
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
GitHub({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}),
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
Credentials({
async authorize(credentials) {
const { email, password } = credentials as {
email: string;
password: string;
};
const user = await prisma.user.findUnique({ where: { email } });
if (!user || !user.hashedPassword) return null;
const valid = await bcrypt.compare(password, user.hashedPassword);
if (!valid) return null;
return { id: user.id, email: user.email, name: user.name };
},
}),
],
callbacks: {
session({ session, token }) {
if (token.sub) session.user.id = token.sub;
return session;
},
},
pages: {
signIn: '/login',
error: '/auth/error',
},
});
Create the route handler:
// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth';
export const { GET, POST } = handlers;
Use the session in Server Components and Client Components:
// Server Component — use auth() directly
import { auth } from '@/auth';
import { redirect } from 'next/navigation';
export default async function ProtectedPage() {
const session = await auth();
if (!session) redirect('/login');
return <div>Hello {session.user.name}</div>;
}
// Client Component — use useSession hook
'use client';
import { useSession, signIn, signOut } from 'next-auth/react';
function NavBar() {
const { data: session, status } = useSession();
if (status === 'loading') return <div>Loading...</div>;
return session ? (
<div>
<span>{session.user.email}</span>
<button onClick={() => signOut()}>Sign out</button>
</div>
) : (
<button onClick={() => signIn('github')}>Sign in with GitHub</button>
);
}
Add the Prisma schema for Auth.js database sessions:
// prisma/schema.prisma — Auth.js required tables
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
hashedPassword String?
accounts Account[]
sessions Session[]
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
Option 3: Lucia (Custom Sessions)
Lucia is a session management library, not a full auth framework. It gives you typed session creation and validation without making decisions about how you handle login forms, password hashing, or user creation. This makes it ideal when you need custom auth flows that do not fit Auth.js's model.
npm install lucia
npm install @lucia-auth/adapter-prisma # or @lucia-auth/adapter-drizzle
Configure Lucia with your database adapter:
// lib/auth.ts — Lucia setup
import { Lucia } from 'lucia';
import { PrismaAdapter } from '@lucia-auth/adapter-prisma';
import { prisma } from '@/lib/prisma';
import { cookies } from 'next/headers';
export const lucia = new Lucia(
new PrismaAdapter(prisma.session, prisma.user),
{
sessionCookie: {
attributes: {
secure: process.env.NODE_ENV === 'production',
},
},
getUserAttributes: (attributes) => ({
email: attributes.email,
name: attributes.name,
}),
}
);
declare module 'lucia' {
interface Register {
Lucia: typeof lucia;
DatabaseUserAttributes: { email: string; name: string };
}
}
// Call this in every protected route to validate the session
export async function validateRequest() {
const sessionId = cookies().get(lucia.sessionCookieName)?.value ?? null;
if (!sessionId) return { user: null, session: null };
const { user, session } = await lucia.validateSession(sessionId);
try {
if (session?.fresh) {
// Extend the session cookie expiry (sliding window)
const sessionCookie = lucia.createSessionCookie(session.id);
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
}
if (!session) {
const blankCookie = lucia.createBlankSessionCookie();
cookies().set(blankCookie.name, blankCookie.value, blankCookie.attributes);
}
} catch {
// cookies().set() throws in static rendering contexts
}
return { user, session };
}
Implement login and logout routes:
// app/api/login/route.ts
import { lucia } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { cookies } from 'next/headers';
import bcrypt from 'bcryptjs';
export async function POST(request: Request) {
const { email, password } = await request.json();
const user = await prisma.user.findUnique({ where: { email } });
if (!user || !await bcrypt.compare(password, user.hashedPassword)) {
return Response.json({ error: 'Invalid credentials' }, { status: 401 });
}
// Create a new session
const session = await lucia.createSession(user.id, {});
const sessionCookie = lucia.createSessionCookie(session.id);
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
return Response.json({ success: true });
}
// app/api/logout/route.ts
export async function POST() {
const { session } = await validateRequest();
if (!session) return Response.json({ error: 'Not authenticated' }, { status: 401 });
await lucia.invalidateSession(session.id);
const blankCookie = lucia.createBlankSessionCookie();
cookies().set(blankCookie.name, blankCookie.value, blankCookie.attributes);
return Response.json({ success: true });
}
Protect routes:
// app/dashboard/page.tsx
import { validateRequest } from '@/lib/auth';
import { redirect } from 'next/navigation';
export default async function Dashboard() {
const { user } = await validateRequest();
if (!user) redirect('/login');
return <div>Welcome, {user.name}</div>;
}
JWT vs Database Sessions
Understanding this trade-off prevents choosing the wrong storage mechanism for your sessions.
Database sessions store a random token in your database. On every request, the server looks up the token and fetches the user. The advantage is revocability — you can instantly invalidate a session by deleting its database record. When a user logs out, clicks "log out of all devices," or gets banned, the session is gone immediately. The disadvantage is a database query on every authenticated request.
JWT (JSON Web Tokens) encode the user's identity and claims in a signed token. No database lookup is required — the server just verifies the signature. The disadvantage is that JWTs cannot be revoked without extra infrastructure (a token denylist, which is essentially a database). If a user's JWT is stolen, it remains valid until it expires.
For most web applications, database sessions are the right choice. The extra database query per request is negligible, and the ability to instantly revoke sessions is valuable. Auth.js and Lucia both use database sessions by default.
JWT is the right choice for stateless APIs consumed by mobile apps or other services, where you want to avoid coupling the API server to a session database. Also appropriate for short-lived tokens: one-time links, email verification tokens, password reset tokens.
// JWT for API authentication — example with jose library
import { SignJWT, jwtVerify } from 'jose';
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
export async function createToken(userId: string) {
return new SignJWT({ sub: userId })
.setProtectedHeader({ alg: 'HS256' })
.setExpirationTime('15m') // Short expiry for access tokens
.sign(secret);
}
export async function verifyToken(token: string) {
const { payload } = await jwtVerify(token, secret);
return payload;
}
Security Checklist
Authentication is a high-value target. These requirements are non-negotiable for production:
Transport security:
- HTTPS everywhere — session cookies must not travel over HTTP
Strict-Transport-Securityheader in production
Cookie security:
HttpOnly: true— prevents JavaScript from reading session cookies (XSS mitigation)Secure: true— cookies only sent over HTTPSSameSite: LaxorStrict— prevents CSRF attacks from third-party sites- All three libraries (Clerk, Auth.js, Lucia) set these correctly by default
Password handling:
- Use bcrypt with at least 12 rounds:
bcrypt.hash(password, 12) - Never store plaintext passwords, never store MD5 or SHA-256 hashes
- Validate password strength on the server (not just client-side)
Rate limiting on auth endpoints:
- Limit login attempts per IP: 5 attempts per 15 minutes
- Use a library like
rate-limiter-flexibleor middleware at the edge
// Example: rate limiting with Upstash Ratelimit (works at the edge)
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(5, '15 m'),
});
// In your login route:
const { success } = await ratelimit.limit(`login:${ip}`);
if (!success) {
return Response.json({ error: 'Too many attempts' }, { status: 429 });
}
Other considerations:
- Use
crypto.timingSafeEqualfor comparing tokens to prevent timing attacks - Implement account lockout or CAPTCHA after repeated failed attempts
- Log authentication events for anomaly detection
Multi-Provider OAuth with Auth.js
One of Auth.js's strongest features is its provider abstraction. Adding a second OAuth provider is a single object in the providers array. Here is a complete multi-provider setup with email magic link fallback:
// auth.ts — multiple providers
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
import Google from 'next-auth/providers/google';
import Discord from 'next-auth/providers/discord';
import Resend from 'next-auth/providers/resend';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { prisma } from '@/lib/prisma';
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
GitHub({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}),
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
Discord({
clientId: process.env.DISCORD_CLIENT_ID!,
clientSecret: process.env.DISCORD_CLIENT_SECRET!,
}),
// Magic link via email (no password needed)
Resend({
apiKey: process.env.RESEND_API_KEY!,
from: 'auth@yourdomain.com',
}),
],
session: {
strategy: 'database', // Use database sessions (not JWT)
maxAge: 30 * 24 * 60 * 60, // 30 days
},
callbacks: {
session({ session, user }) {
// Expose user ID in the session object
session.user.id = user.id;
return session;
},
redirect({ url, baseUrl }) {
// Allow relative URLs and same-origin redirects
if (url.startsWith('/')) return `${baseUrl}${url}`;
if (new URL(url).origin === baseUrl) return url;
return baseUrl;
},
},
});
Each OAuth provider requires creating an OAuth application in the respective developer console to get the client ID and secret. GitHub and Google have the most straightforward setup flows. Resend (or any other email provider like Postmark) enables magic link login where users click a link in their email instead of entering a password — a significantly better user experience for many applications.
User Signup with Password Hashing
When implementing email/password authentication with Auth.js Credentials or Lucia, the signup flow is your responsibility. Here is a production-quality signup endpoint:
// app/api/signup/route.ts
import { prisma } from '@/lib/prisma';
import bcrypt from 'bcryptjs';
import { z } from 'zod';
const signupSchema = z.object({
email: z.string().email(),
password: z.string().min(8).max(100),
name: z.string().min(1).max(100),
});
export async function POST(request: Request) {
const body = await request.json();
// Validate input
const result = signupSchema.safeParse(body);
if (!result.success) {
return Response.json({ error: 'Invalid input' }, { status: 400 });
}
const { email, password, name } = result.data;
// Check if user already exists
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
// Use a generic error to avoid user enumeration
return Response.json({ error: 'Account creation failed' }, { status: 400 });
}
// Hash password with bcrypt — 12 rounds is the recommended minimum in 2026
const hashedPassword = await bcrypt.hash(password, 12);
const user = await prisma.user.create({
data: { email, name, hashedPassword },
});
return Response.json({ success: true, userId: user.id }, { status: 201 });
}
The important details: use Zod for input validation (never trust raw user input), return a generic error message for both "email already exists" and validation failures to prevent user enumeration attacks, and use bcrypt with 12 rounds. At 12 rounds, bcrypt takes about 250ms to hash a password on modern hardware — slow enough that brute-force attacks are impractical, fast enough that it doesn't noticeably impact user experience.
Package Health
| Package | Weekly Downloads | License | Free Tier |
|---|---|---|---|
| @clerk/nextjs | ~1M | Proprietary | Up to 10K MAU |
| next-auth (Auth.js) | ~2.5M | ISC | Fully open source |
| lucia | ~500K | MIT | Fully open source |
| @lucia-auth/adapter-prisma | ~300K | MIT | Fully open source |
Auth.js has the highest download count because it has been the default Next.js auth solution since 2020. Clerk's growth rate is the fastest in the space, gaining adoption as teams prioritize shipping speed over cost. Lucia targets developers who want to understand exactly what their auth implementation does.
Protecting API Routes
Authentication in the UI is only half the picture. Your API routes need protection independently — a logged-out user can call your API endpoints directly even if the UI hides the buttons.
With Clerk:
// app/api/protected/route.ts
import { auth } from '@clerk/nextjs/server';
export async function GET() {
const { userId } = auth();
if (!userId) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
// userId is verified — safe to use
const data = await fetchUserData(userId);
return Response.json(data);
}
With Auth.js:
// app/api/protected/route.ts
import { auth } from '@/auth';
export async function GET() {
const session = await auth();
if (!session?.user?.id) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
const data = await fetchUserData(session.user.id);
return Response.json(data);
}
With Lucia:
// app/api/protected/route.ts
import { validateRequest } from '@/lib/auth';
export async function GET() {
const { user } = await validateRequest();
if (!user) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
const data = await fetchUserData(user.id);
return Response.json(data);
}
Each approach validates the session on every request to protected API routes. There is no way to skip this — client-side auth state (useUser(), useSession()) is for UI rendering only. The server must independently verify the session for every API call.
For tRPC users, put the auth check in the middleware or context creation so every procedure gets the session automatically without boilerplate in each handler.
Session Expiry and Sliding Windows
One detail that many authentication implementations get wrong is session lifetime management. There are two main models: fixed expiry and sliding window.
A fixed expiry session expires at a fixed point in time after creation, regardless of activity. If you set sessions to expire after 30 days, a user who logs in and uses the app every day will still be logged out after 30 days. This is simpler to implement and is the correct model for high-security applications (banking, healthcare) where you want guaranteed session termination.
A sliding window session extends its expiry whenever the user actively uses the app. If a session expires after 30 days of inactivity, a user who logs in daily will never be forced to re-authenticate. This is a better experience for most consumer applications where you want "stay logged in" behavior. Lucia supports this with the session.fresh flag shown in the code above — when a session is fresh (recently used), the cookie's expiry is extended.
Auth.js supports sliding window sessions via the session.maxAge and updateAge settings:
// auth.ts
session: {
strategy: 'database',
maxAge: 30 * 24 * 60 * 60, // 30 days total
updateAge: 24 * 60 * 60, // Extend expiry if used within 24 hours
}
Clerk handles this automatically — sessions extend on activity, and you can configure the lifetime in the Clerk dashboard without touching code.
Common Mistakes to Avoid
Authentication bugs are often not visible until an attacker exploits them. Here are the mistakes worth knowing before you deploy:
Storing session data in localStorage. Session tokens in localStorage are readable by any JavaScript on the page, including third-party scripts and XSS payloads. Always store session tokens in HttpOnly cookies that JavaScript cannot access. All three libraries (Clerk, Auth.js, Lucia) use HttpOnly cookies by default.
Not validating the session on API routes. Client-side auth state (useUser(), isSignedIn) is for rendering decisions only. Any API route that returns data or modifies state must independently validate the session. An attacker can call your API endpoints directly without going through your UI.
Using MD5 or SHA-256 for password hashing. These are fast hashing algorithms designed for checksums, not passwords. They can be brute-forced with modern GPUs at billions of hashes per second. Use bcrypt (12+ rounds), argon2id, or scrypt — slow by design, making brute-force impractical.
Not setting SameSite on session cookies. Without SameSite: Lax or Strict, your session cookies are sent with cross-site requests, enabling CSRF attacks. All three libraries set this correctly, but if you implement custom cookie handling, this is easy to miss.
When to Choose
| Scenario | Pick |
|---|---|
| Want auth done in under an hour | Clerk |
| Need MFA, social login, enterprise SSO out of the box | Clerk |
| Budget sensitive, need free self-hosted | Auth.js v5 |
| Next.js + OAuth + database sessions | Auth.js v5 |
| Custom auth flow that doesn't fit a framework | Lucia |
| Full control over session management | Lucia |
| Compliance requires on-prem data storage | Lucia or Auth.js |
| API-only with mobile clients | JWT (jose library) |
| Team new to auth security | Clerk (they handle the hard parts) |
Further Reading
See the live comparison
View clerk vs. nextauth on PkgPulse →