A pragmatic auth setup for a new Next.js 16 app: signed JWT sessions stored in HTTP-only cookies. Simple, secure, no third-party service required.
jose¶1npm i josejose works in both Node and Edge runtimes — important since middleware runs at the edge.
1// lib/session.ts
2import "server-only";
3import { SignJWT, jwtVerify, type JWTPayload } from "jose";
4import { cookies } from "next/headers";
5
6const SECRET = new TextEncoder().encode(process.env.SESSION_SECRET!);
7const COOKIE = "session";
8const MAX_AGE = 60 * 60 * 24 * 30; // 30 days
9
10export type SessionPayload = JWTPayload & { uid: string; role: "USER" | "ADMIN" };
11
12export async function createSession(payload: SessionPayload) {
13 const token = await new SignJWT(payload)
14 .setProtectedHeader({ alg: "HS256" })
15 .setIssuedAt()
16 .setExpirationTime("30d")
17 .sign(SECRET);
18
19 (await cookies()).set(COOKIE, token, {
20 httpOnly: true,
21 secure: process.env.NODE_ENV === "production",
22 sameSite: "lax",
23 path: "/",
24 maxAge: MAX_AGE,
25 });
26}
27
28export async function readSession(): Promise<SessionPayload | null> {
29 const token = (await cookies()).get(COOKIE)?.value;
30 if (!token) return null;
31 try {
32 const { payload } = await jwtVerify(token, SECRET);
33 return payload as SessionPayload;
34 } catch {
35 return null;
36 }
37}
38
39export async function destroySession() {
40 (await cookies()).delete(COOKIE);
41}1// app/(auth)/login/actions.ts
2"use server";
3import { redirect } from "next/navigation";
4import bcrypt from "bcryptjs";
5import { db } from "@/lib/db";
6import { createSession } from "@/lib/session";
7
8export async function login(prev: unknown, formData: FormData) {
9 const email = formData.get("email")?.toString() ?? "";
10 const password = formData.get("password")?.toString() ?? "";
11
12 const user = await db.user.findUnique({ where: { email } });
13 if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
14 return { error: "Invalid credentials" };
15 }
16
17 await createSession({ uid: user.id, role: user.role });
18 redirect("/dashboard");
19}1"use server";
2import { destroySession } from "@/lib/session";
3import { redirect } from "next/navigation";
4
5export async function logout() {
6 await destroySession();
7 redirect("/login");
8}1// lib/auth.ts
2import { cache } from "react";
3import { readSession } from "@/lib/session";
4import { db } from "@/lib/db";
5
6export const getCurrentUser = cache(async () => {
7 const session = await readSession();
8 if (!session) return null;
9 return db.user.findUnique({ where: { id: session.uid } });
10});cache() ensures a single DB lookup per request, even if many components call getCurrentUser().
1// middleware.ts
2import { jwtVerify } from "jose";
3
4const SECRET = new TextEncoder().encode(process.env.SESSION_SECRET!);
5
6export async function middleware(req: NextRequest) {
7 const token = req.cookies.get("session")?.value;
8 if (!token) return NextResponse.redirect(new URL("/login", req.url));
9 try {
10 await jwtVerify(token, SECRET);
11 return NextResponse.next();
12 } catch {
13 return NextResponse.redirect(new URL("/login", req.url));
14 }
15}For OAuth providers (Google, GitHub, Apple), magic links, and account linking, use Auth.js (next-auth v5). It plugs into the same cookie/session model and saves you weeks of work.