If you used NextAuth v4, you remember the friction: an authOptions object imported into every file that needed it, getServerSession here, getToken there, a different API depending on whether you were in a page or an API route. v5 collapses all of it into one file. You create auth.ts at the project root, call NextAuth() once, and export the four things the rest of your app will ever need.
import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [GitHub],
pages: { signIn: "/login" },
callbacks: {
session({ session, token }) {
if (token.sub) session.user.id = token.sub;
return session;
},
},
});The export that matters is auth(). It is the same function everywhere on the server — server components, route handlers, server actions, middleware. Call it, get back the session or null. That uniformity sounds small until you maintain an app where the session is read in nine different places; in v4 those were three different APIs, and one of them was usually used wrong.
The second export, handlers, powers a catch-all route. Everything NextAuth does over HTTP — OAuth callbacks, CSRF tokens, the sign-out endpoint — lives under /api/auth/. You write two lines and never touch them again:
import { handlers } from "@/auth";
export const { GET, POST } = handlers;warning
Run npx auth secret once in development — it generates AUTH_SECRET and writes it to .env.local. Then set the same variable in your production environment by hand, because .env.local never leaves your machine. Skip that step and the deploy that worked locally throws [auth][error] MissingSecret on the server, which surfaces as a 500 on every auth route. One more property worth knowing: AUTH_SECRET encrypts your session cookies, so rotating it signs every user out. Painful by accident, useful after an incident.
The reason to wire GitHub or Google before anything else is not convenience. It is that the most dangerous column your database can have is a password column. People reuse passwords, which means a breach of your side project can become a breach of someone's email account. With OAuth there is no password to leak: the provider holds the credential, you receive a token you can revoke, and your worst-case scenario shrinks from "mass credential theft" to "revoke and apologise".
The mechanics take about ten minutes. Register an OAuth app in your GitHub developer settings with the callback URL http://localhost:3000/api/auth/callback/github, then put the credentials in .env.local as AUTH_GITHUB_ID and AUTH_GITHUB_SECRET. v5 reads provider credentials by naming convention, which is why providers: [GitHub] in the config above takes no arguments at all.
Create a second OAuth app for production with your real domain in the callback URL. One app cannot serve both environments — the moment your deployed site sends users to GitHub with only a localhost callback registered, they get a redirect_uri mismatch error and you get a confused bug report.
Sometimes you genuinely need email and password — users without GitHub accounts, corporate environments that block third-party login. Fine. But understand what you just signed up for: you are now storing secrets that people reuse on other sites, and the difference between doing it right and doing it almost right is the difference between an incident report and a mass account takeover.
The failure mode is rarely exotic. It is a developer comparing the submitted password to a stored one with ===, or hashing with SHA-256 because hashing is hashing, right? It is not. SHA-256 is designed to be fast, and fast is fatal here: a commodity GPU can test billions of SHA-256 guesses per second against a leaked table. bcrypt exists to be slow on purpose. At cost factor 12 each guess costs real compute — somewhere around a quarter of a second on ordinary hardware, as a rough ballpark — which turns a weekend of cracking into a project nobody bothers to finish.
// inside the providers array
import Credentials from "next-auth/providers/credentials";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/prisma";
import { loginSchema } from "@/lib/validations";
Credentials({
credentials: { email: {}, 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?.passwordHash) return null;
const valid = await bcrypt.compare(parsed.data.password, user.passwordHash);
return valid ? user : null;
},
})Three details in that block carry most of the security weight. The schema parse, because credentials arrive as untyped user input and TypeScript types validate nothing at runtime. The bcrypt.compare, never a string comparison. And returning null on every failure path, so an unknown email and a wrong password produce the identical response — return different errors and you have built an oracle that confirms which emails exist in your database. On the registration side, the matching rule: hash with bcrypt.hash(password, 12) before the insert, cost 12 or higher.
NextAuth supports two session strategies, and the choice is really one question: when you need to end a session, how fast does it have to die?
The JWT strategy — the default — stores the session in an encrypted, httpOnly cookie. No database read on any request, which means auth() is essentially free and works in middleware without a round-trip. The cost: you cannot revoke one specific session before its token expires. A stolen cookie stays valid until expiry, a banned user stays signed in for the rest of their token's lifetime, and "log out of all devices" is not a button you can build without extra machinery.
The database strategy stores a session row per login and checks it on every request. Revocation is instant — delete the row and the session is dead. You can show users their active devices and let them kill one. The cost is a database query on every auth() call, plus an adapter:
// switching to database sessions
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: "database" },
providers: [GitHub],
});The Prisma adapter needs four models in your schema — User, Account, Session, and VerificationToken. Copy them from the Auth.js documentation and run a migration; do not hand-roll them, because the field names are part of the contract. And one caveat that surprises everyone exactly once: Credentials sign-ins always use JWT sessions, even with an adapter configured — the adapter never creates session rows for password logins. If you need database sessions and credentials login together, you are writing custom session handling, which is a strong hint to reconsider one of the two requirements.
My rule: JWT until you handle money or personal data, or until you need a real "sign out everywhere" feature. Then database sessions, and the query per request is the price of being able to pull the plug.
Middleware is the right place to redirect signed-out visitors, because it runs before anything renders — no flash of a protected page, no wasted server work.
import { auth } from "@/auth";
export default auth((req) => {
if (!req.auth) {
const url = new URL("/login", req.nextUrl.origin);
url.searchParams.set("callbackUrl", req.nextUrl.pathname);
return Response.redirect(url);
}
});
export const config = {
matcher: ["/dashboard/:path*", "/settings/:path*"],
};Now the part that matters more: treat that as user experience, not as the security boundary. In March 2025, CVE-2025-29927 let attackers skip Next.js middleware entirely with a single crafted header. It was patched quickly, but the lesson is permanent — any single gate eventually fails. Apps whose only auth check lived in middleware were wide open that week. Apps that checked again at the data layer shrugged.
So the rule is defense in depth: every page, every route handler, every server action that touches protected data calls auth() again. Hiding a button is not security. A redirect is not security. The check that counts is the one standing next to the data.
import { auth } from "@/auth";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const session = await auth();
if (!session?.user) redirect("/login");
return <h1>Welcome back, {session.user.name}</h1>;
}"use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
export async function deletePost(postId: string) {
const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized");
// the authorId filter means users can only delete their own posts
await prisma.post.delete({
where: { id: postId, authorId: session.user.id },
});
}Notice the delete query filters by authorId as well as id. That is authorization, the question after authentication: not "is this a signed-in user" but "is this their post". Skipping that second question is how an attacker with a perfectly valid account of their own deletes everyone else's data by guessing IDs.
warning
The classic redirect loop: you set pages.signIn to /login, then write a middleware matcher that also covers /login. An anonymous visitor requests /dashboard, gets redirected to /login, the middleware sees no session there and redirects to /login again — until the browser gives up with ERR_TOO_MANY_REDIRECTS. Keep your sign-in page and /api/auth out of every protected matcher.
NextAuth answers one question well: who is this? It deliberately does not answer "is someone abusing the login form?" For a portfolio project that is fine. The moment your app touches money or personal data, the following stop being optional — and none of them ship in the box:
- Rate limiting. Nothing stops an attacker from trying passwords as fast as your server responds. Put a limiter in front of the credentials flow — Upstash Ratelimit works well on Vercel — and keep it strict: five attempts per minute per IP is plenty for humans.
- Account lockout. Track failed attempts per account and lock for fifteen minutes after five failures. A failedAttempts counter and a lockedUntil timestamp on the User model is all it takes.
- Multi-factor authentication. There is no built-in second factor. TOTP via an authenticator app, or passkeys, are yours to build — enrollment, challenge, recovery codes, all of it.
- Password reset and email verification. The flows are yours: single-use tokens, one-hour expiry, and a response that never reveals whether the email was registered.
- Session inventory. "You are signed in on three devices" requires database sessions plus a UI you write yourself.
None of this is a criticism. It is the difference between a library and a product, and NextAuth is honest about where it stops. Budget for these the way you budget for tests — as part of the feature, not as a someday.
Before auth goes anywhere near production, walk this list. Every line is something I have seen skipped, and every skip has a story attached.
- AUTH_SECRET is set in the production environment, and it was generated, not typed.
- Production has its own OAuth app, and the callback URL matches the deployed domain exactly.
- Passwords are hashed with bcrypt at cost 12 or higher — and never appear in logs, error messages, or analytics events.
- The login error is identical for an unknown email and a wrong password.
- Every route handler and server action that touches protected data calls auth() itself — middleware is a convenience on top, not the check.
- Mutations verify ownership, not just sign-in status.
- The middleware matcher excludes /login and /api/auth.
- The session strategy was chosen on purpose, with a revocation answer if you handle money or personal data.
- The credentials endpoint is rate limited.
- You tested while logged out: protected URLs in a private window, API routes with curl and no cookie. Every single one said no.
Auth is the feature that makes a project real — the moment your app knows who someone is, it can hold work that belongs to them, and you have users instead of visitors. So do not practice this on a toy. Take the project you actually want people to use, wire up GitHub OAuth this week, and add credentials login only when a real user asks for it. The checklist above is the bar; clearing it once, properly, teaches you more than ten tutorials that stop at "it works on localhost".
