Type something to search...
Next.js Implementing authentication

Next.js Implementing authentication

Authentication is one of those features every real application needs and almost nobody wants to build from scratch. Next.js doesn't ship an opinionated auth system the way some frameworks do, and that's deliberate — auth requirements vary too much between projects for a one-size-fits-all solution to make sense. Instead, the App Router gives you a set of primitives (Server Actions, cookies, Suspense, caching) that you compose into whatever auth flow your app needs, whether that's a hand-rolled username/password system or a wrapper around a hosted provider like Clerk or Auth0.

That flexibility is a double-edged sword. It means you can build exactly what you need, but it also means there's no "just works" auth checkbox to tick. This guide walks through the three concepts that make up any auth system in Next.js — authentication, session management, and authorization — and shows you the actual mechanics of wiring them together with Server Actions, cookies, and the Data Access Layer pattern. By the end you'll understand what a library like NextAuth.js or Better Auth is actually doing under the hood, which makes debugging them (and deciding whether you even need one) a lot less mysterious.

One clarification before we start: if Cache Components is enabled in your project, reading the session and caching per-user data follows a slightly different set of rules than what's described here, because a lot of "read the session, render something user-specific" work has to be pushed inside <Suspense> boundaries rather than awaited at the top of a layout. That's covered in a separate guide. Everything below assumes the more traditional model, which is still what most projects run.

The Three Pieces of Auth

It helps to keep these three concepts separate in your head, because conflating them is where most home-grown auth systems go wrong:

Authentication answers "who are you?" — verifying a user's identity, typically via a password, a magic link, or an OAuth provider.

Session management answers "how do we remember you across requests?" — HTTP is stateless, so something has to persist the fact that a user authenticated, usually a cookie holding a token or session ID.

Authorization answers "what are you allowed to do?" — given that we know who you are, can you view this dashboard, edit this post, or hit this API route?

Nearly every authentication bug I've seen in production traces back to one of these three getting tangled up with another — usually authorization logic living only in the UI layer instead of at the data layer, which we'll get to.

Why You Should Still Understand How This Works (Even If You Use a Library)

I'm going to say this once clearly: for a production application, use an authentication library. NextAuth.js (now branded Auth.js), Clerk, Better Auth, Supabase Auth, and similar tools have already solved the hard, security-critical parts — password hashing, token rotation, CSRF protection, social login flows, rate limiting on login attempts — and they've had far more eyes on their code than your custom implementation ever will.

That said, the example below builds a minimal username/password flow from raw parts. Not because you should ship it, but because every one of those libraries is doing exactly this underneath their nice APIs, and understanding the moving parts makes you dramatically better at configuring, extending, and debugging them. If you've ever stared at a NextAuth.js callback function with no idea what it's actually doing to your cookies, this is the missing context.

Step 1: Authentication — Capturing and Validating Credentials

The App Router's <form> element pairs with Server Actions to give you a secure place to handle credentials — the form submission executes entirely on the server, so there's no client-side JavaScript touching passwords before they're validated.

Start with a form that posts to a Server Action:

// app/ui/signup-form.tsx
import { signup } from "@/app/actions/auth";

export function SignupForm() {
  return (
    <form action={signup}>
      <div>
        <label htmlFor="name">Name</label>
        <input id="name" name="name" placeholder="Name" />
      </div>
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" placeholder="Email" />
      </div>
      <div>
        <label htmlFor="password">Password</label>
        <input id="password" name="password" type="password" />
      </div>
      <button type="submit">Sign Up</button>
    </form>
  );
}

On its own, that form does nothing — it needs a signup Server Action to receive the FormData. Before touching a database, validate the input. I reach for Zod here because its .safeParse() API returns errors as data instead of throwing, which fits neatly into a Server Action's return value:

// app/lib/definitions.ts
import * as z from "zod";

export const SignupFormSchema = z.object({
  name: z
    .string()
    .min(2, { error: "Name must be at least 2 characters." })
    .trim(),
  email: z.email({ error: "Enter a valid email address." }).trim(),
  password: z
    .string()
    .min(8, { error: "Must be at least 8 characters." })
    .regex(/[a-zA-Z]/, { error: "Must contain a letter." })
    .regex(/[0-9]/, { error: "Must contain a number." })
    .regex(/[^a-zA-Z0-9]/, { error: "Must contain a special character." })
    .trim(),
});

export type FormState =
  | {
      errors?: { name?: string[]; email?: string[]; password?: string[] };
      message?: string;
    }
  | undefined;
// app/actions/auth.ts
"use server";

import { SignupFormSchema, type FormState } from "@/app/lib/definitions";

export async function signup(state: FormState, formData: FormData) {
  const validated = SignupFormSchema.safeParse({
    name: formData.get("name"),
    email: formData.get("email"),
    password: formData.get("password"),
  });

  if (!validated.success) {
    return { errors: validated.error.flatten().fieldErrors };
  }

  // proceed to create the user...
}

Returning early on invalid input matters for more than UX — it means you never run a database query for garbage input, which closes off a cheap denial-of-service vector.

Back in the form, wire useActionState so validation errors render without a full page reload:

// app/ui/signup-form.tsx
"use client";

import { useActionState } from "react";
import { signup } from "@/app/actions/auth";

export function SignupForm() {
  const [state, action, pending] = useActionState(signup, undefined);

  return (
    <form action={action}>
      <div>
        <label htmlFor="name">Name</label>
        <input id="name" name="name" placeholder="Name" />
      </div>
      {state?.errors?.name && <p>{state.errors.name}</p>}

      <div>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" placeholder="Email" />
      </div>
      {state?.errors?.email && <p>{state.errors.email}</p>}

      <div>
        <label htmlFor="password">Password</label>
        <input id="password" name="password" type="password" />
      </div>
      {state?.errors?.password && (
        <ul>
          {state.errors.password.map((error) => (
            <li key={error}>{error}</li>
          ))}
        </ul>
      )}

      <button disabled={pending} type="submit">
        Sign Up
      </button>
    </form>
  );
}

Once the fields pass validation, hash the password (never store it in plain text — bcrypt is the standard choice) and insert the user:

// app/actions/auth.ts (continued)
import bcrypt from "bcrypt";
import { db } from "@/app/lib/db";
import { users } from "@/app/lib/schema";

const { name, email, password } = validated.data;
const hashedPassword = await bcrypt.hash(password, 10);

const [user] = await db
  .insert(users)
  .values({ name, email, password: hashedPassword })
  .returning({ id: users.id });

if (!user) {
  return { message: "Something went wrong creating your account." };
}

// next: create a session, then redirect

At this point you have a verified identity but no way to remember it on the next request. That's what session management solves.

Step 2: Session Management — Remembering Who Signed In

You have two broad options for where session state lives:

Stateless sessions put the session payload (or a signed token) directly in a browser cookie. The server verifies it by checking the signature — no database round-trip required. Simpler to implement, but revoking a single session before its expiry requires extra bookkeeping (a blocklist, typically).

Database sessions store session data server-side, with the browser holding only an opaque session ID. Revocation is trivial — delete the row — but every authenticated request now needs a database read (which you can mitigate with caching).

Most projects start stateless and move to database sessions once they need instant logout-everywhere or session auditing. I'll walk through stateless first since it illustrates the mechanics most clearly.

Generating a Signing Secret

Whatever you use to sign or encrypt session tokens needs a secret key that never reaches the client:

openssl rand -base64 32

Store the output as an environment variable — SESSION_SECRET — and never commit it. If this key ever leaks, every session in your system is forgeable.

Encrypting and Decrypting the Session Payload

Jose is a solid, dependency-light choice for signing JWTs. Note the import "server-only" at the top — that's not decoration, it's a real guardrail: if any Client Component accidentally imports this module, the build fails instead of silently shipping your secret key to the browser.

// app/lib/session.ts
import "server-only";
import { SignJWT, jwtVerify } from "jose";

const encodedKey = new TextEncoder().encode(process.env.SESSION_SECRET);

export async function encrypt(payload: { userId: string; expiresAt: Date }) {
  return new SignJWT(payload)
    .setProtectedHeader({ alg: "HS256" })
    .setIssuedAt()
    .setExpirationTime("7d")
    .sign(encodedKey);
}

export async function decrypt(token = "") {
  try {
    const { payload } = await jwtVerify(token, encodedKey, {
      algorithms: ["HS256"],
    });
    return payload;
  } catch {
    return undefined;
  }
}

A detail worth internalizing: whatever you put in that payload is only encrypted from the browser's perspective — anyone with the secret key (i.e., your server) can read it, and if you ever accidentally log it, it's plaintext in your logs. Keep the payload to the bare minimum needed to identify the user — an ID, maybe a role — never a phone number, a full email, or anything you'd hesitate to paste into a support ticket.

Setting the Cookie Correctly

This is the part people get subtly wrong most often. The cookie options aren't decoration — each one closes a specific attack vector:

// app/lib/session.ts (continued)
import { cookies } from "next/headers";

export async function createSession(userId: string) {
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
  const session = await encrypt({ userId, expiresAt });

  (await cookies()).set("session", session, {
    httpOnly: true, // blocks document.cookie access — mitigates XSS token theft
    secure: true, // cookie only sent over HTTPS
    expires: expiresAt,
    sameSite: "lax", // blocks most CSRF vectors without breaking normal navigation
    path: "/",
  });
}

httpOnly: true is the one I see skipped most often, usually because someone wanted to read the session cookie from client-side JavaScript for a "logged in" UI flag. Don't do that — if you need client-visible auth state, expose a separate, non-sensitive cookie or fetch it, don't loosen the session cookie itself.

Note that cookies() is async in current Next.js — you must await it before calling .set(), .get(), or .delete(). Forgetting the await is a common source of confusing type errors when upgrading an older auth implementation.

Back in the Server Action, create the session and redirect:

// app/actions/auth.ts (continued)
import { redirect } from "next/navigation";
import { createSession } from "@/app/lib/session";

await createSession(user.id);
redirect("/profile");

redirect() throws internally to interrupt execution — nothing after it in the function runs, so don't rely on cleanup code placed below a redirect() call.

Refreshing and Deleting Sessions

Extend a session's lifetime on activity so active users don't get logged out mid-use:

// app/lib/session.ts (continued)
export async function updateSession() {
  const cookieStore = await cookies();
  const token = cookieStore.get("session")?.value;
  const payload = await decrypt(token);
  if (!token || !payload) return null;

  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
  cookieStore.set("session", token, {
    httpOnly: true,
    secure: true,
    expires: expiresAt,
    sameSite: "lax",
    path: "/",
  });
}

And logging out is just deleting the cookie:

// app/lib/session.ts (continued)
export async function deleteSession() {
  (await cookies()).delete("session");
}
// app/actions/auth.ts
"use server";
import { redirect } from "next/navigation";
import { deleteSession } from "@/app/lib/session";

export async function logout() {
  await deleteSession();
  redirect("/login");
}

Database Sessions, Briefly

If you need instant revocation or session auditing, store the session row in your database and keep only an encrypted session ID in the cookie:

// app/lib/session.ts
export async function createSession(userId: number) {
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);

  const [{ id: sessionId }] = await db
    .insert(sessions)
    .values({ userId, expiresAt })
    .returning({ id: sessions.id });

  const token = await encrypt({ sessionId, expiresAt });

  (await cookies()).set("session", token, {
    httpOnly: true,
    secure: true,
    expires: expiresAt,
    sameSite: "lax",
    path: "/",
  });
}

Logging a user out everywhere becomes a single DELETE FROM sessions WHERE user_id = ? instead of an unsolvable problem with stateless JWTs. The tradeoff is a database read on every request unless you add caching in front of it — which is exactly what the Data Access Layer pattern below gives you a natural place to do.

Step 3: Authorization — Deciding What a Session Can Do

Authentication and session management get you to "we know who this is." Authorization is the layer that actually protects your data, and it's where most real-world vulnerabilities live — not in the crypto, but in a forgotten check on one specific route or Server Action.

There are two flavors of authorization check, and conflating them is a common mistake:

Optimistic checks read the session straight out of the cookie — fast, no database hit, good for redirecting unauthenticated users away from a route or hiding UI elements. Not sufficient on their own for protecting actual data.

Secure checks verify the session against the database and are what you use immediately before returning or mutating sensitive data.

Optimistic Checks in Proxy

Next.js runs a proxy.ts file (this replaced the older middleware.ts convention) on every matching request before it reaches a route, which makes it a convenient place to redirect unauthenticated users early:

// proxy.ts
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
import { decrypt } from "@/app/lib/session";

const protectedRoutes = ["/dashboard"];
const publicRoutes = ["/login", "/signup", "/"];

export default async function proxy(req: NextRequest) {
  const path = req.nextUrl.pathname;
  const session = await decrypt((await cookies()).get("session")?.value);

  if (protectedRoutes.includes(path) && !session?.userId) {
    return NextResponse.redirect(new URL("/login", req.nextUrl));
  }

  if (
    publicRoutes.includes(path) &&
    session?.userId &&
    !path.startsWith("/dashboard")
  ) {
    return NextResponse.redirect(new URL("/dashboard", req.nextUrl));
  }

  return NextResponse.next();
}

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

Two things to keep in mind here. First, Proxy also runs against prefetched routes, so only ever do the cheap, cookie-only check here — a database call in Proxy adds latency to every navigation, prefetch included. Second, and more important: Proxy is not a security boundary. It's a convenience for redirecting users to the right page early. Treat it as UX polish, not protection, because it's trivial to hit a Server Action or Route Handler directly, bypassing Proxy entirely.

The Data Access Layer — Where Real Protection Lives

The pattern that actually closes the gap is centralizing every authorization check in one place — a Data Access Layer (DAL) — rather than scattering if (session.role !== 'admin') checks across components and hoping you remembered all of them.

// app/lib/dal.ts
import "server-only";
import { cache } from "react";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { decrypt } from "@/app/lib/session";

export const verifySession = cache(async () => {
  const session = await decrypt((await cookies()).get("session")?.value);
  if (!session?.userId) redirect("/login");
  return { userId: session.userId as string };
});

export const getUser = cache(async () => {
  const session = await verifySession();
  const [user] = await db.query.users.findMany({
    where: eq(users.id, session.userId),
    columns: { id: true, name: true, email: true }, // never select password, tokens, etc.
  });
  return user ?? null;
});

Wrapping both functions in React's cache() means that no matter how many components on a page call getUser(), the underlying query runs once per render. This is the single most important pattern in this whole guide: every data-fetching function that returns anything user-specific should call verifySession() (or equivalent) internally, not rely on the caller having already checked. That way, forgetting an auth check in one component doesn't create a hole — the check lives with the data, not with every place the data is used.

Data Transfer Objects

Related discipline: return only the fields a caller actually needs, rather than a whole database row. If you fetch a full User object with a password hash and session tokens on it "just in case," you've created a standing risk that some future console.log(user) or accidental client serialization leaks it.

// app/lib/dto.ts
import "server-only";
import { getUser } from "@/app/lib/dal";

export async function getProfileDTO(slug: string) {
  const [profile] = await db.query.users.findMany({
    where: eq(users.slug, slug),
  });
  const viewer = await getUser();

  return {
    username: profile.username,
    phoneNumber:
      viewer?.isAdmin || viewer?.team === profile.team
        ? profile.phoneNumber
        : null,
  };
}

Where Auth Checks Actually Belong

Server Components can call verifySession() directly and branch on the result — this is the most common place role-based rendering happens.

Layouts are the wrong place for the actual security check. This trips people up because it feels natural to protect a whole route group by checking auth once in its layout. The problem: layouts don't re-render on client-side navigation between sibling routes, so a check that ran once when the layout first mounted won't re-run as the user navigates deeper. Fetch shared data (like the current user, for a nav avatar) in the layout if you want, but do the actual authorization check inside the DAL function that data-fetching calls into — not as an if statement in the layout component itself.

Server Actions and Route Handlers must check authorization themselves, every time. Treat them exactly like public API endpoints, because that's what they are — anyone can construct a request to them directly, bypassing whatever UI restricts the button that normally triggers them.

// app/lib/actions.ts
"use server";
import { verifySession } from "@/app/lib/dal";

export async function deletePost(postId: string) {
  const session = await verifySession();
  const post = await getPostOwner(postId);

  if (post.ownerId !== session.userId) {
    throw new Error("Not authorized to delete this post");
  }

  await db.delete(posts).where(eq(posts.id, postId));
}

Leaf components that conditionally render admin-only buttons should still run their own check — hiding a button is a UX nicety, not protection, since the underlying action must independently verify authorization anyway.

Mistakes I See Repeatedly

Returning null from a layout to "hide" a route for unauthorized users. Next.js has multiple entry points into a route — direct navigation, Server Actions, prefetching, parallel route slots — and a layout choosing not to render its children doesn't stop those other entry points from executing. The data or action underneath is still reachable.

Putting a database read behind Proxy. Proxy runs on every request including prefetches; a database round-trip there adds latency across your entire app, not just the pages that need it.

Blocking the first byte on a session check. If a shared layout awaits cookies() or the DAL at the top level, every route under it waits on that work before anything streams. If only a small piece of shell UI (a user avatar, a "Sign in" link) needs the session, move that await into its own nested Server Component and wrap it in <Suspense> so the rest of the page streams immediately.

Trying to import the DAL into a Client Component. It won't work — Client Components can't import server-only code. Fetch the data in a Server Component and pass it down as props, or through a context provider, instead.

Storing anything sensitive in a JWT payload. Remember: encrypted from the browser's view, plaintext to you. A password hash, full session token, or any PII in that payload is one accidental console.log away from your logs.

Skipping httpOnly "to make debugging easier." It's tempting during development to read the session cookie from client JS. Ship that to production and you've handed any successful XSS an easy way to steal live sessions.

Key Takeaways

ConceptWhat it doesWhere it lives
AuthenticationVerifies identity via credentialsServer Action + useActionState
Stateless sessionSigned payload stored in a cookiejose/JWT, cookies() API
Database sessionOpaque ID in cookie, data server-sideSession table + cache
Optimistic authorizationFast, cookie-only checkproxy.ts — UX only, not security
Secure authorizationVerified against the databaseData Access Layer (verifySession)
Data exposure controlReturn only necessary fieldsDTOs

Authentication in Next.js isn't a feature you enable — it's an architecture you build out of a handful of well-understood primitives: Server Actions for capturing credentials, cookies() for session storage, and a Data Access Layer for centralizing every authorization check so it can't be accidentally skipped. Whether you build this by hand or hand it off to Clerk or Auth.js, the mental model is the same, and now you know exactly what's happening underneath the API calls.

Tags :
Share :

Related Posts

Can Next.js Be Used with GraphQL?

Can Next.js Be Used with GraphQL?

Next.js and GraphQL are two powerful technologies that have gained significant traction in the web development community. Next.js, a React-based fram

Dive Deeper
How does Next.js differ from Create React App?

How does Next.js differ from Create React App?

In the world of modern web development, React.js has emerged as a dominant force due to its flexibility, performance, and extensive ecosystem. Two po

Dive Deeper
How does Next.js handle image optimization?

How does Next.js handle image optimization?

In modern web development, image optimization plays a critical role in enhancing user experience and improving site performance. Large, unoptimized i

Dive Deeper