| name | auth-flows |
| description | Authentication patterns for React apps — Auth.js (NextAuth), Clerk, Supabase Auth. Covers session management, protected routes, role-based access, and OAuth flows. |
| triggers | ["authentication","auth","login","sign in","sign up","session","protected route","authorization","OAuth","clerk","supabase auth","nextauth"] |
Authentication Flows
Decision Tree — Choosing an Auth Provider
Which framework?
├── Next.js
│ ├── Need full control / self-hosted? → Auth.js v5
│ ├── Want managed UI + user management? → Clerk
│ └── Already using Supabase DB? → Supabase Auth
├── Vite / Remix / SPA
│ ├── Want drop-in components? → Clerk
│ └── Using Supabase backend? → Supabase Auth
| Provider | Best For | Hosting | UI Components |
|---|
| Auth.js v5 | Full control, self-hosted, Next.js | Self-hosted | Custom (build your own) |
| Clerk | Fast setup, managed users, any framework | Managed SaaS | Built-in (SignIn, SignUp, UserButton) |
| Supabase Auth | Supabase stack, row-level security | Supabase cloud / self-hosted | Headless (build your own) |
1. Auth.js v5 (NextAuth) — Next.js
Installation
pnpm add next-auth@beta @auth/prisma-adapter
Configuration
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 { z } from "zod";
import bcrypt from "bcryptjs";
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
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({
name: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
const parsed = loginSchema.safeParse(credentials);
if (!parsed.success) return null;
const user = await prisma.user.findUnique({
where: { email: parsed.data.email },
});
if (!user?.hashedPassword) return null;
const valid = await bcrypt.compare(
parsed.data.password,
user.hashedPassword
);
if (!valid) return null;
return { id: user.id, email: user.email, name: user.name, role: user.role };
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.role = user.role;
}
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.sub!;
session.user.role = token.role as string;
}
return session;
},
},
pages: {
signIn: "/login",
error: "/auth/error",
newUser: "/onboarding",
},
session: {
strategy: "jwt",
},
});
Type Augmentation
import { type DefaultSession } from "next-auth";
declare module "next-auth" {
interface Session {
user: {
id: string;
role: string;
} & DefaultSession["user"];
}
interface User {
role: string;
}
}
declare module "next-auth/jwt" {
interface JWT {
role: string;
}
}
Route Handler
import { handlers } from "@/auth";
export const { GET, POST } = handlers;
Middleware for Protected Routes
import { auth } from "@/auth";
import { NextResponse } from "next/server";
const protectedPaths = ["/dashboard", "/settings", "/admin"];
const adminPaths = ["/admin"];
export default auth((req) => {
const { pathname } = req.nextUrl;
const isProtected = protectedPaths.some((p) => pathname.startsWith(p));
const isAdmin = adminPaths.some((p) => pathname.startsWith(p));
if (isProtected && !req.auth) {
const loginUrl = new URL("/login", req.url);
loginUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(loginUrl);
}
if (isAdmin && req.auth?.user?.role !== "admin") {
return NextResponse.redirect(new URL("/unauthorized", req.url));
}
return NextResponse.next();
});
export const config = {
matcher: ["/dashboard/:path*", "/settings/:path*", "/admin/:path*"],
};
Server Component Access
import { auth } from "@/auth";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
return (
<div>
<h1>Dashboard</h1>
<p>Welcome, {session.user.name}</p>
<p>Role: {session.user.role}</p>
</div>
);
}
Client Component Access
"use client";
import { useSession, signIn, signOut } from "next-auth/react";
export function UserNav() {
const { data: session, status } = useSession();
if (status === "loading") {
return <div aria-busy="true">Loading...</div>;
}
if (!session) {
return (
<button onClick={() => signIn()}>Sign In</button>
);
}
return (
<div className="flex items-center gap-4">
<span>{session.user.name}</span>
<button onClick={() => signOut({ callbackUrl: "/" })}>
Sign Out
</button>
</div>
);
}
Session Provider (Layout)
import { SessionProvider } from "next-auth/react";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<SessionProvider>{children}</SessionProvider>
</body>
</html>
);
}
2. Clerk — Any Framework
Installation
pnpm add @clerk/nextjs
Environment Variables
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_...
CLERK_SECRET_KEY=sk_...
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
Middleware
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
const isPublicRoute = createRouteMatcher([
"/",
"/sign-in(.*)",
"/sign-up(.*)",
"/api/webhooks(.*)",
]);
export default clerkMiddleware(async (auth, req) => {
if (!isPublicRoute(req)) {
await auth.protect();
}
});
export const config = {
matcher: [
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
"/(api|trpc)(.*)",
],
};
Layout Provider
import { ClerkProvider } from "@clerk/nextjs";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<ClerkProvider>
<html lang="en">
<body>{children}</body>
</html>
</ClerkProvider>
);
}
Built-in Components
import { SignIn } from "@clerk/nextjs";
export default function SignInPage() {
return (
<div className="flex min-h-screen items-center justify-center">
<SignIn />
</div>
);
}
import { SignUp } from "@clerk/nextjs";
export default function SignUpPage() {
return (
<div className="flex min-h-screen items-center justify-center">
<SignUp />
</div>
);
}
import { SignedIn, SignedOut, UserButton, SignInButton } from "@clerk/nextjs";
export function Header() {
return (
<header className="flex items-center justify-between p-4">
<h1>My App</h1>
<nav>
<SignedIn>
<UserButton afterSignOutUrl="/" />
</SignedIn>
<SignedOut>
<SignInButton mode="modal" />
</SignedOut>
</nav>
</header>
);
}
Server-Side Auth
import { auth, currentUser } from "@clerk/nextjs/server";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const { userId } = await auth();
if (!userId) {
redirect("/sign-in");
}
const user = await currentUser();
return (
<div>
<h1>Dashboard</h1>
<p>Welcome, {user?.firstName}</p>
</div>
);
}
3. Supabase Auth
Installation
pnpm add @supabase/supabase-js @supabase/ssr
Browser Client
import { createBrowserClient } from "@supabase/ssr";
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}
Server Client (Next.js App Router)
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {
}
},
},
}
);
}
Middleware
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
export async function middleware(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
);
supabaseResponse = NextResponse.next({ request });
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
);
},
},
}
);
const {
data: { user },
} = await supabase.auth.getUser();
if (
!user &&
!request.nextUrl.pathname.startsWith("/login") &&
!request.nextUrl.pathname.startsWith("/auth") &&
request.nextUrl.pathname.startsWith("/dashboard")
) {
const url = request.nextUrl.clone();
url.pathname = "/login";
return NextResponse.redirect(url);
}
return supabaseResponse;
}
Sign In / Sign Up
"use client";
import { createClient } from "@/lib/supabase/client";
import { useState } from "react";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const supabase = createClient();
async function handleSignIn(e: React.FormEvent) {
e.preventDefault();
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
setError(error.message);
} else {
window.location.href = "/dashboard";
}
}
async function handleOAuth(provider: "github" | "google") {
await supabase.auth.signInWithOAuth({
provider,
options: {
redirectTo: `${window.location.origin}/auth/callback`,
},
});
}
return (
<form onSubmit={handleSignIn}>
{error && <p role="alert" className="text-red-600">{error}</p>}
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<button type="submit">Sign In</button>
<div className="flex gap-2 mt-4">
<button type="button" onClick={() => handleOAuth("github")}>
Continue with GitHub
</button>
<button type="button" onClick={() => handleOAuth("google")}>
Continue with Google
</button>
</div>
</form>
);
}
4. Role-Based Access Control (RBAC)
A framework-agnostic RBAC pattern that works with any auth provider.
export const roles = {
admin: {
permissions: [
"users:read",
"users:write",
"users:delete",
"posts:read",
"posts:write",
"posts:delete",
"settings:read",
"settings:write",
],
},
editor: {
permissions: [
"posts:read",
"posts:write",
"posts:delete",
"users:read",
],
},
viewer: {
permissions: [
"posts:read",
"users:read",
],
},
} as const;
export type Role = keyof typeof roles;
export type Permission =
(typeof roles)[Role]["permissions"][number];
export function hasPermission(
userRole: Role,
permission: Permission
): boolean {
const role = roles[userRole];
return (role.permissions as readonly string[]).includes(permission);
}
export function requirePermission(
userRole: Role,
permission: Permission
): void {
if (!hasPermission(userRole, permission)) {
throw new Error(
`Role "${userRole}" does not have permission "${permission}"`
);
}
}
RequirePermission Component
"use client";
import { type ReactNode } from "react";
import { hasPermission, type Permission, type Role } from "@/lib/auth/permissions";
interface RequirePermissionProps {
role: Role;
permission: Permission;
children: ReactNode;
fallback?: ReactNode;
}
export function RequirePermission({
role,
permission,
children,
fallback = null,
}: RequirePermissionProps) {
if (!hasPermission(role, permission)) {
return <>{fallback}</>;
}
return <>{children}</>;
}
Usage:
<RequirePermission
role={session.user.role}
permission="users:delete"
fallback={<p>You do not have permission to manage users.</p>}
>
<UserManagementPanel />
</RequirePermission>
Server-Side Permission Check
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { requirePermission, type Role } from "@/lib/auth/permissions";
export default async function AdminUsersPage() {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
try {
requirePermission(session.user.role as Role, "users:write");
} catch {
redirect("/unauthorized");
}
return <UserManagementPanel />;
}
5. Security Rules
These rules are NON-NEGOTIABLE for any auth implementation:
| Rule | Reason |
|---|
Never store tokens in localStorage | XSS attacks can steal them. Use httpOnly cookies. |
Always use httpOnly cookies for sessions | Cannot be accessed by client-side JavaScript. |
| Validate sessions server-side | Never trust client-side session data for authorization decisions. |
| Implement CSRF protection | Auth.js and Next.js include CSRF tokens automatically. Verify for custom APIs. |
| Rate limit auth endpoints | Prevent brute force attacks. Use middleware or API route rate limiting. |
| Hash passwords with bcrypt (cost 12+) | Never store plaintext or weakly-hashed passwords. |
| Rotate refresh tokens | Invalidate old tokens when issuing new ones to limit exposure. |
| Validate redirect URLs | Only allow redirects to trusted origins. Never redirect to user-supplied URLs without validation. |
Use secure and sameSite cookie flags | secure: true in production, sameSite: "lax" minimum. |
| Log authentication events | Record sign-ins, failures, and password resets for audit trails. |
Redirect URL Validation
const ALLOWED_HOSTS = [
process.env.NEXT_PUBLIC_APP_URL,
"localhost:3000",
];
export function isValidRedirectUrl(url: string): boolean {
try {
const parsed = new URL(url, process.env.NEXT_PUBLIC_APP_URL);
return ALLOWED_HOSTS.some(
(host) => parsed.origin === new URL(`https://${host}`).origin
);
} catch {
return false;
}
}
export function getSafeRedirect(url: string | null, fallback = "/"): string {
if (!url) return fallback;
return isValidRedirectUrl(url) ? url : fallback;
}