End-to-end auth patterns for Next.js — middleware route protection, server-side session checks, role-based access control, API route guards, and secure token handling. Examples with NextAuth.js and Clerk.
You are a security-focused Next.js engineer. Follow these patterns for all auth-related code.
Protect your app at three layers. Never rely on just one.
1. Middleware/Proxy → Redirect unauthenticated users (fast, edge-level)
2. Server Components → Verify session before rendering (data-level)
3. API Routes → Validate token on every mutation (enforcement-level)
Block unauthenticated access before the page even renders.
// Next.js 16: proxy.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
const isPublicRoute = createRouteMatcher([
'/',
'/explore(.*)',
'/sign-in(.*)',
'/sign-up(.*)',
'/api/webhooks(.*)',
]);
export default clerkMiddleware(async (auth, req) => {
if (!isPublicRoute(req)) {
await auth.protect();
}
});
Verify the session and fetch user data in server components.
// lib/auth.ts
import { cache } from 'react';
import { auth, currentUser } from '@clerk/nextjs/server';
export const getSession = cache(async () => {
const { userId } = await auth();
if (!userId) return null;
return { userId };
});
export async function requireAuth() {
const { userId, redirectToSignIn } = await auth();
if (!userId) return redirectToSignIn();
return { userId };
}
// page.tsx — server component
export default async function DashboardPage() {
const { userId } = await requireAuth();
const data = await getUserData(userId);
return <Dashboard data={data} />;
}
Every mutation endpoint must verify authentication independently.
// lib/auth.ts
export async function requireAuthApi(): Promise<string> {
const session = await getSession();
if (!session) {
throw new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
return session.userId;
}
// app/api/posts/route.ts
export async function POST(req: Request) {
const userId = await requireAuthApi(); // Throws 401 if not authenticated
const body = await req.json();
const post = await createPost({ ...body, authorId: userId });
return Response.json(post, { status: 201 });
}
Authentication = who you are. Authorization = what you can do. Always check ownership before mutations.
export async function updatePost(postId: string, data: UpdateData) {
const session = await getSession();
if (!session) throw new Error('Not authenticated');
const post = await db.post.findUnique({
where: { id: postId },
select: { authorId: true }, // Fetch only what's needed
});
if (!post) throw new Error('Post not found');
if (post.authorId !== session.userId) throw new Error('Not authorized');
return db.post.update({ where: { id: postId }, data });
}
For admin-only routes, check roles at the API level.
// Simple approach: env-based admin list
const ADMIN_IDS = (process.env.ADMIN_USER_IDS ?? '').split(',').filter(Boolean);
export function requireAdmin(userId: string) {
if (!ADMIN_IDS.includes(userId)) {
throw new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403 });
}
}
// Usage in API route
export async function POST(req: Request) {
const userId = await requireAuthApi();
requireAdmin(userId);
// Admin-only logic
}
External auth providers (Clerk, Auth0) manage users outside your database. Webhooks sync them, but webhooks can fail. Add a safety net.
import { cache } from 'react';
import { currentUser } from '@clerk/nextjs/server';
export const ensureDbUser = cache(async () => {
const clerkUser = await currentUser();
if (!clerkUser) return null;
const existing = await db.user.findUnique({ where: { id: clerkUser.id } });
if (existing) return existing;
return db.user.create({
data: {
id: clerkUser.id,
username: clerkUser.username ?? clerkUser.id,
displayName: clerkUser.firstName ?? 'User',
avatarUrl: clerkUser.imageUrl,
},
});
});
// Call in layout — runs once per request
export default async function AppLayout({ children }) {
await ensureDbUser();
return <>{children};
}
Always verify webhook signatures. Never trust the payload without verification.
import { verifyWebhook } from '@clerk/nextjs/webhooks';
export async function POST(req: Request) {
let event;
try {
event = await verifyWebhook(req);
} catch {
return new Response('Invalid signature', { status: 400 });
}
switch (event.type) {
case 'user.created':
await syncUser(event.data);
break;
case 'user.deleted':
await deleteUser(event.data.id);
break;
}
return new Response('OK', { status: 200 });
}
Use provider components to conditionally render based on auth state.
import { SignedIn, SignedOut, SignInButton, UserButton } from '@clerk/nextjs';
function Header() {
return (
<nav>
<Logo />
<SignedIn>
<UserButton />
</SignedIn>
<SignedOut>
<SignInButton mode="modal" />
</SignedOut>
</nav>
);
}
requireAuthApi()