AI Reading
Quick summary of this article
Add secure authentication to Next.js 16 with Auth.js v5: OAuth and credentials providers, session handling, route protection in proxy.ts, and role-based authorisation enforced server-side.
- Next.js 16 renames
middleware.tstoproxy.ts— it targets the Node.js runtime and Auth.js wraps it cleanly. - Auth.js v5 exports
handlers,auth,signInandsignOutfrom oneauth.tsfile. proxy.tsis a convenience layer for redirects — every Server Action and Route Handler must still authorise independently.- Role checks belong in the data layer, not in the UI, because hidden buttons are not access control.
Complete Next.js 16 Series — Module 10 of 12
Authentication is the part of a Next.js app that is most often subtly wrong. This module builds it correctly: one auth config, sessions on the server, protection in proxy.ts, and authorisation checks where the data actually lives.
Installing Auth.js
npm install next-auth@beta
npx auth secret
next-auth@beta is Auth.js v5, the version designed for the App Router — v4 has no Server Component support. npx auth secret writes AUTH_SECRET into .env.local; generate a separate one for production.
Status note: Auth.js v5 is still published under the
betatag. It is widely used in production, but pin the exact version inpackage.jsonso an upgrade cannot surprise you.
One Config File
src/auth.ts:
import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
import Credentials from "next-auth/providers/credentials";
import { z } from "zod";
import bcrypt from "bcryptjs";
import { db } from "@/lib/db";
export const { handlers, auth, signIn, signOut } = NextAuth({
session: { strategy: "jwt" },
pages: { signIn: "/login" },
providers: [
GitHub({
clientId: process.env.AUTH_GITHUB_ID,
clientSecret: process.env.AUTH_GITHUB_SECRET,
}),
Credentials({
credentials: { email: {}, password: {} },
async authorize(raw) {
const parsed = z
.object({ email: z.email(), password: z.string().min(8) })
.safeParse(raw);
if (!parsed.success) return null;
const user = await db.user.findUnique({ where: { email: parsed.data.email } });
if (!user?.passwordHash) return null;
const valid = await bcrypt.compare(parsed.data.password, user.passwordHash);
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 as { role?: string }).role ?? "USER";
token.uid = user.id;
}
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.uid as string;
session.user.role = token.role as "USER" | "EDITOR" | "ADMIN";
}
return session;
},
},
});
Add the type augmentation so session.user.role is typed everywhere — src/types/next-auth.d.ts:
import type { DefaultSession } from "next-auth";
declare module "next-auth" {
interface Session {
user: { id: string; role: "USER" | "EDITOR" | "ADMIN" } & DefaultSession["user"];
}
}
The Route Handler
// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth";
export const { GET, POST } = handlers;
That is the entire auth endpoint: callback URLs, CSRF protection, session reads, sign-in and sign-out.
Reading the Session on the Server
In a Server Component or Server Action:
import { auth } from "@/auth";
import { redirect } from "next/navigation";
export default async function AccountPage() {
const session = await auth();
if (!session?.user) redirect("/login?callbackUrl=/account");
return (
<div className="mx-auto max-w-lg px-6 py-12">
<h1 className="text-2xl font-bold">Welcome, {session.user.name}</h1>
<p className="text-sm text-neutral-500">Role: {session.user.role}</p>
</div>
);
}
auth() reads the session cookie on the server. No client-side provider, no useSession fetch, no loading spinner — the HTML arrives already personalised.
Protecting Routes with proxy.ts
In Next.js 16, middleware.ts is deprecated and renamed to proxy.ts. The runtime is Node.js and is not configurable.
// src/proxy.ts
import { auth } from "@/auth";
import { NextResponse } from "next/server";
export default auth((req) => {
const { pathname } = req.nextUrl;
const isLoggedIn = !!req.auth;
const role = req.auth?.user?.role;
// Only /admin/* requires ADMIN; /dashboard/* requires any session
if (pathname.startsWith("/admin") && role !== "ADMIN") {
return NextResponse.redirect(new URL("/403", req.nextUrl));
}
if (pathname.startsWith("/dashboard") && !isLoggedIn) {
const login = new URL("/login", req.nextUrl);
login.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(login);
}
return NextResponse.next();
});
export const config = {
matcher: ["/dashboard/:path*", "/admin/:path*"],
};
The matcher keeps the proxy off every static asset and public page — running it globally is the number one performance mistake with middleware-style files.
proxy.tsis a routing convenience, not a security boundary. It can only redirect. A Server Action invoked directly over HTTP never passes through it. Authorise again inside every action.
Signing In and Out
Client-side buttons:
"use client";
import { signIn, signOut } from "next-auth/react";
export function SignInButton() {
return (
<button
onClick={() => signIn("github", { callbackUrl: "/dashboard" })}
className="rounded bg-neutral-900 px-4 py-2 text-white"
>
Continue with GitHub
</button>
);
}
export function SignOutButton() {
return (
<button onClick={() => signOut({ callbackUrl: "/" })} className="text-sm text-neutral-600">
Sign out
</button>
);
}
Or from a Server Action, which is better for progressive enhancement:
"use server";
import { signIn, signOut } from "@/auth";
export async function signInWithGitHub() {
await signIn("github", { redirectTo: "/dashboard" });
}
export async function signOutAction() {
await signOut({ redirectTo: "/" });
}
Authorisation: The Part People Get Wrong
Hiding an admin link is not authorisation. Anyone can POST to your Server Action directly. Enforce access at three layers:
// src/lib/authz.ts
import "server-only";
import { auth } from "@/auth";
const RANK = { USER: 1, EDITOR: 2, ADMIN: 3 } as const;
export type Role = keyof typeof RANK;
export async function requireUser() {
const session = await auth();
if (!session?.user) throw new Error("UNAUTHENTICATED");
return session.user;
}
export async function requireRole(minimum: Role) {
const user = await requireUser();
if (RANK[user.role] < RANK[minimum]) throw new Error("FORBIDDEN");
return user;
}
Then in every mutating action:
"use server";
import { requireRole } from "@/lib/authz";
export async function deletePost(id: string) {
const user = await requireRole("EDITOR");
const post = await db.post.findUnique({ where: { id } });
if (!post) throw new Error("NOT_FOUND");
// Editors may only delete their own posts; admins may delete any.
if (user.role !== "ADMIN" && post.authorId !== user.id) {
throw new Error("FORBIDDEN");
}
await db.post.delete({ where: { id } });
revalidateTag("posts", "max");
}
That ownership check is the difference between a demo and a product. Never trust an ID from the client without verifying who owns it.
Securing Cookies and Secrets
AUTH_SECRET="<openssl rand -base64 32>"
AUTH_URL="https://yourdomain.com"
AUTH_GITHUB_ID="..."
AUTH_GITHUB_SECRET="..."
AUTH_SECRETsigns the session JWT — rotate it and every session is invalidated.AUTH_URL(orAUTH_TRUST_HOST=truebehind a proxy) prevents callback mismatches.- Session cookies are
httpOnly,secureandsameSite=laxby default in Auth.js v5.
Store password hashes with bcrypt (cost 12) or argon2id. Never store or log plaintext passwords, and never return a “user not found” message that distinguishes an unknown email from a wrong password.
Module 10 Recap
- Auth.js v5 (
next-auth@beta) is the App Router-ready version; pin it. auth.tsexportshandlers,auth,signIn,signOut; the catch-all route re-exportshandlers.auth()in Server Components gives the session with no client fetch.proxy.tsreplacesmiddleware.tsin Next.js 16, runs on Node.js, and needs a tightmatcher.- Authorise in every Server Action and Route Handler — the proxy cannot protect them.
- Centralise checks in
requireUser()/requireRole()and always verify resource ownership.
Next up — Module 11: performance — images, fonts, streaming, bundle analysis and Cache Components.
Continue the Course
- Course index: Next.js 16 Tutorial – all 12 modules
- Previous: Module 9: Prisma 7 & PostgreSQL
- Next: Module 11: Performance & Streaming
