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.role from 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 .