Authentication is one of those things that should be simple but somehow never is. Every time I start a new Next.js project, I spend way too long deciding between NextAuth, Clerk, Auth0, Supabase Auth, or just rolling my own.

For most of my projects I ended up rolling my own. Not because I think I’m smarter than the library authors, but because the libraries kept adding complexity I didn’t need. Magic session objects, callback URLs, provider configs, database adapters. For a project with email and password login, that’s overkill.

Here’s the setup I use. It’s not the most secure system ever built, but it works, it’s simple, and I understand every line of it.


The approach: JWT in HTTP-only cookies

The idea is straightforward:

  1. User logs in with email and password
  2. Server verifies credentials, creates a JWT
  3. JWT is stored in an HTTP-only cookie
  4. Middleware checks the cookie on protected routes

No session database. No token refresh endpoints. No OAuth providers. Just a signed token in a cookie.


Setting up JWT helpers

I use jose for JWT operations because it works in Edge Runtime (which Next.js middleware runs in). jsonwebtoken doesn’t work there.

// lib/auth.ts
import { SignJWT, jwtVerify } from "jose";

const secret = new TextEncoder().encode(process.env.JWT_SECRET);

export async function signToken(payload: { userId: string; email: string }) {
  return new SignJWT(payload)
    .setProtectedHeader({ alg: "HS256" })
    .setExpirationTime("7d")
    .sign(secret);
}

export async function verifyToken(token: string) {
  try {
    const { payload } = await jwtVerify(token, secret);
    return payload as { userId: string; email: string };
  } catch {
    return null;
  }
}

Two functions. One signs a token, one verifies it. That’s the entire auth utility.


The login API route

// app/api/auth/login/route.ts
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db/prisma";
import { signToken } from "@/lib/auth";
import bcrypt from "bcryptjs";

export async function POST(req: Request) {
  const { email, password } = await req.json();

  if (!email || !password) {
    return NextResponse.json(
      { success: false, error: { message: "Email and password are required" } },
      { status: 400 }
    );
  }

  const user = await prisma.user.findUnique({ where: { email } });

  if (!user || !(await bcrypt.compare(password, user.password))) {
    return NextResponse.json(
      { success: false, error: { message: "Invalid credentials" } },
      { status: 401 }
    );
  }

  const token = await signToken({ userId: user.id, email: user.email });

  const response = NextResponse.json({ success: true });

  response.cookies.set("token", token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    maxAge: 60 * 60 * 24 * 7, // 7 days
    path: "/",
  });

  return response;
}

The important bits: httpOnly means JavaScript can’t access the cookie (prevents XSS from stealing tokens). secure means it only works over HTTPS in production. sameSite: "lax" prevents CSRF for most cases.


The register route

// app/api/auth/register/route.ts
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db/prisma";
import { signToken } from "@/lib/auth";
import bcrypt from "bcryptjs";

export async function POST(req: Request) {
  const { email, password, name } = await req.json();

  if (!email || !password || !name) {
    return NextResponse.json(
      { success: false, error: { message: "All fields are required" } },
      { status: 400 }
    );
  }

  const existing = await prisma.user.findUnique({ where: { email } });

  if (existing) {
    return NextResponse.json(
      { success: false, error: { message: "Email already in use" } },
      { status: 409 }
    );
  }

  const hashed = await bcrypt.hash(password, 10);

  const user = await prisma.user.create({
    data: { email, password: hashed, name },
  });

  const token = await signToken({ userId: user.id, email: user.email });

  const response = NextResponse.json({ success: true });

  response.cookies.set("token", token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    maxAge: 60 * 60 * 24 * 7,
    path: "/",
  });

  return response;
}

Register creates the user and logs them in immediately. No “check your email” step. For most side projects that’s unnecessary friction.


Protecting routes with middleware

This is where Next.js middleware shines. One file that runs before every request:

// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { verifyToken } from "@/lib/auth";

const protectedRoutes = ["/dashboard", "/settings", "/api/protected"];
const authRoutes = ["/login", "/register"];

export async function middleware(req: NextRequest) {
  const token = req.cookies.get("token")?.value;
  const user = token ? await verifyToken(token) : null;

  const { pathname } = req.nextUrl;

  // redirect authenticated users away from login/register
  if (user && authRoutes.some((route) => pathname.startsWith(route))) {
    return NextResponse.redirect(new URL("/dashboard", req.url));
  }

  // redirect unauthenticated users to login
  if (!user && protectedRoutes.some((route) => pathname.startsWith(route))) {
    return NextResponse.redirect(new URL("/login", req.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

No auth checks in individual pages. No HOCs wrapping components. The middleware handles it all before the page even starts rendering.


Getting the current user in server components

For server components that need the user’s data:

// lib/auth.ts (add this to the existing file)
import { cookies } from "next/headers";

export async function getCurrentUser() {
  const token = cookies().get("token")?.value;
  if (!token) return null;

  const payload = await verifyToken(token);
  if (!payload) return null;

  return prisma.user.findUnique({
    where: { id: payload.userId },
    select: { id: true, name: true, email: true },
  });
}

Then in any server component:

import { getCurrentUser } from "@/lib/auth";
import { redirect } from "next/navigation";

export default async function DashboardPage() {
  const user = await getCurrentUser();
  if (!user) redirect("/login");

  return <h1>Welcome, {user.name}</h1>;
}

The middleware already handles redirects, but having getCurrentUser available is useful for pages that behave differently based on auth state.


The logout route

// app/api/auth/logout/route.ts
import { NextResponse } from "next/server";

export async function POST() {
  const response = NextResponse.json({ success: true });

  response.cookies.set("token", "", {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    maxAge: 0,
    path: "/",
  });

  return response;
}

Set the cookie with maxAge: 0 and it’s gone. That’s the entire logout logic.


When to use a library instead

This setup works great for side projects and apps where you control the entire auth flow. But I’d reach for a library when:

  • You need OAuth (Google, GitHub, etc.) — handling the redirect flows yourself is painful
  • You need email verification, password reset, and account recovery — that’s a lot of code to maintain
  • You’re building something with strict security requirements — battle-tested libraries handle edge cases you won’t think of

For those cases, NextAuth or Lucia are solid choices. But for a straightforward email/password app, this setup takes 30 minutes and you understand every piece of it.


Simple is secure enough

I know some people will say this isn’t production-ready. And for a banking app, they’re right. But for a dashboard, a SaaS MVP, or a side project, this covers all the basics: hashed passwords, signed tokens, HTTP-only cookies, route protection.

You can always add complexity later when you actually need it. Starting with a 200-line auth config file for a project that has 3 users is a waste of time.