Next.js auth cheatsheet.
Auth.js (NextAuth v5)
npm i next-auth@beta @auth/prisma-adapter
auth.config.ts:
import type { NextAuthConfig } from "next-auth";
export default {
pages: { signIn: "/login" },
providers: [],
callbacks: {
authorized({ auth, request: { nextUrl } }) {
const onDashboard = nextUrl.pathname.startsWith("/dashboard");
if (onDashboard) return !!auth?.user;
return true;
},
},
} satisfies NextAuthConfig;
auth.ts:
import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
import authConfig from "./auth.config";
export const { auth, handlers, signIn, signOut } = NextAuth({
...authConfig,
providers: [
GitHub({
clientId: process.env.AUTH_GITHUB_ID,
clientSecret: process.env.AUTH_GITHUB_SECRET,
}),
],
});
Route handlers
// app/api/auth/[...nextauth]/route.ts
export { GET, POST } from "@/auth";
Middleware
// middleware.ts
export { auth as middleware } from "@/auth";
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};
Uses the authorized callback to gate routes.
Reading session
// Server component
import { auth } from "@/auth";
async function Page() {
const session = await auth();
if (!session?.user) return null;
return <p>Hi {session.user.email}</p>;
}
Client component
"use client";
import { useSession } from "next-auth/react";
function NavBar() {
const { data: session, status } = useSession();
if (status === "loading") return null;
if (!session) return <a href="/login">Sign in</a>;
return <p>{session.user?.email}</p>;
}
Wrap app:
// app/providers.tsx
"use client";
import { SessionProvider } from "next-auth/react";
export function Providers({ children }) {
return <SessionProvider>{children}</SessionProvider>;
}
Sign in / out
// Server action
import { signIn, signOut } from "@/auth";
<form action={async () => { "use server"; await signIn("github"); }}>
<button>Sign in with GitHub</button>
</form>
<form action={async () => { "use server"; await signOut(); }}>
<button>Sign out</button>
</form>
Credentials provider
import Credentials from "next-auth/providers/credentials";
Credentials({
credentials: { email: {}, password: {} },
authorize: async (creds) => {
const user = await db.user.findUnique({ where: { email: creds.email as string } });
if (!user || !await bcrypt.compare(creds.password as string, user.password)) {
return null;
}
return { id: user.id, email: user.email, name: user.name };
},
}),
Database sessions
import { PrismaAdapter } from "@auth/prisma-adapter";
import { db } from "@/lib/db";
export const { auth, handlers } = NextAuth({
adapter: PrismaAdapter(db),
session: { strategy: "database" },
providers: [...],
});
JWT (default) is faster; database sessions allow revoke.
Augmenting session type
// types/next-auth.d.ts
import "next-auth";
declare module "next-auth" {
interface Session {
user: {
id: string;
role: "admin" | "user";
email: string;
};
}
}
callbacks: {
session({ session, user }) {
session.user.id = user.id;
session.user.role = (user as any).role;
return session;
},
}
Manual session (custom)
For full control, roll your own:
// lib/auth.ts
import { cookies } from "next/headers";
import { jwtVerify, SignJWT } from "jose";
const secret = new TextEncoder().encode(process.env.AUTH_SECRET);
export async function createSession(userId: number) {
const token = await new SignJWT({ userId })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("7d")
.sign(secret);
const c = await cookies();
c.set("session", token, { httpOnly: true, secure: true, sameSite: "lax" });
}
export async function getSession() {
const c = await cookies();
const token = c.get("session")?.value;
if (!token) return null;
try {
const { payload } = await jwtVerify(token, secret);
return payload as { userId: number };
} catch { return null; }
}
export async function destroySession() {
const c = await cookies();
c.delete("session");
}
Authorize in server action
"use server";
import { auth } from "@/auth";
export async function deletePost(id: number) {
const session = await auth();
if (!session) throw new Error("Unauthorized");
if (session.user.role !== "admin") throw new Error("Forbidden");
await db.post.delete({ where: { id } });
}
Never trust the client.
Protecting routes
// app/dashboard/layout.tsx
import { auth } from "@/auth";
import { redirect } from "next/navigation";
export default async function Layout({ children }) {
const session = await auth();
if (!session) redirect("/login");
return children;
}
Common mistakes
- Trusting
user.rolefrom client — always re-check on server. - JWT secrets in code — use env vars, rotate.
- Long-lived sessions without refresh.
- Cookie without
httpOnly→ XSS-readable. - Middleware-only auth — bypassable via direct API hits without re-checking.
Read this next
If you want my Auth.js setup with Prisma + GitHub + magic link, it’s at rajpoot.dev .
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .