EthicsComputerCourses
Join training +91 90532 10052

Authentication Patterns

Next.js · Lesson 22 of 27 Advanced 7 min read Updated

Most real apps have logins: students see their own marks, teachers edit attendance, admins change fees. Getting this wrong can expose private data, so it is worth understanding the pieces even if you later use a ready-made library. This lesson explains the patterns the Next.js documentation recommends, without tying you to one product.

You will learn the three parts of auth, how a login form works with a Server Action, how sessions are stored in secure cookies, and where to check permissions so that a forgotten check in one place does not open a hole.

Three separate jobs

  1. Authentication: proving who the user is, for example with an email and password, an OTP, or "Sign in with Google".
  2. Session management: remembering that this browser belongs to that user on later requests, usually with a cookie.
  3. Authorisation: deciding what the user may see and do: a student may read only their own marks; only admins may change fees.

Writing all of this yourself is hard to do safely. The Next.js docs recommend an auth library for real projects and list several that support the App Router, such as Auth.js (NextAuth.js), Better Auth, Clerk and Supabase Auth. The patterns below are what those libraries do for you, and they are also what you write if you build a simple system for practice.

Logging in with a Server Action

A login form posts to a Server Action, which validates input, checks the password against a stored hash, creates a session and redirects:

JavaScript
// app/login/actions.js
"use server";

import bcrypt from "bcryptjs";
import { redirect } from "next/navigation";
import { createSession } from "@/lib/session";
import { findUserByEmail } from "@/lib/users";

export async function login(prevState, formData) {
  const email = String(formData.get("email") ?? "").trim().toLowerCase();
  const password = String(formData.get("password") ?? "");
  if (!email || !password) return { error: "Enter your email and password." };

  const user = await findUserByEmail(email);
  const ok = user && (await bcrypt.compare(password, user.passwordHash));
  if (!ok) return { error: "Email or password is wrong." };

  await createSession(user.id, user.role);
  redirect("/dashboard");
}

Passwords are never stored as plain text: at sign-up you save await bcrypt.hash(password, 10), and at login you compare. Notice the error message does not say which part was wrong, so attackers cannot discover which emails are registered. The form itself uses useActionState exactly as in Server Actions & Forms.

Sessions in secure cookies

There are two common ways to store a session:

  • Stateless: the session data (user ID, role, expiry) is put into a cookie that is signed or encrypted with a secret key, so the server can detect any change. Simple, no database lookup.
  • Database: the cookie holds only a random session ID; the data lives in a sessions table. A little more work, but you can log users out of all devices.

Either way, set the cookie on the server with safe options:

JavaScript
// lib/session.js
import "server-only";
import { cookies } from "next/headers";
import { encrypt } from "@/lib/crypto"; // signs/encrypts with process.env.SESSION_SECRET

export async function createSession(userId, role) {
  const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
  const token = await encrypt({ userId, role, expires });
  (await cookies()).set("session", token, {
    httpOnly: true,  // JavaScript in the page cannot read it
    secure: true,    // sent only over HTTPS
    sameSite: "lax", // not sent on most cross-site requests
    expires,
    path: "/",
  });
}

export async function deleteSession() {
  (await cookies()).delete("session");
}

Why does signing matter? Because a cookie is stored in the user's browser, and the user can edit it. The runnable example signs a session with HMAC-SHA256 using the browser's built-in Web Crypto API, then shows that changing "student" to "admin" breaks the signature:

React JSXTry it yourself
import { createRoot } from "react-dom/client";

const enc = new TextEncoder();
const secret = "demo-secret-use-a-long-random-value-in-real-apps";
const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);

const toB64 = (bytes) => btoa(String.fromCharCode(...new Uint8Array(bytes)));
const fromB64 = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));

async function sign(payload) {
  const body = btoa(JSON.stringify(payload));
  const sig = await crypto.subtle.sign("HMAC", key, enc.encode(body));
  return body + "." + toB64(sig);
}

async function verify(token) {
  const [body, sig] = token.split(".");
  const ok = await crypto.subtle.verify("HMAC", key, fromB64(sig), enc.encode(body));
  return ok ? JSON.parse(atob(body)) : null;
}

const token = await sign({ userId: 42, role: "student" });
console.log("Valid token ->", JSON.stringify(await verify(token)));

const [body, sig] = token.split(".");
const edited = JSON.parse(atob(body));
edited.role = "admin";
const tampered = btoa(JSON.stringify(edited)) + "." + sig;
console.log("Tampered token ->", JSON.stringify(await verify(tampered)));

createRoot(document.getElementById("root")).render(<p>Check the console: only the untouched token is accepted.</p>);
Output
Valid token -> {"userId":42,"role":"student"}
Tampered token -> null

Real projects use a library such as jose (JSON Web Tokens) or iron-session for this, and keep the secret in an environment variable created with a command like openssl rand -base64 32.

Where to check permissions

The docs describe two kinds of checks:

  • Optimistic checks read only the cookie. They are fast, so they suit Proxy: "no session cookie? redirect to /login". They improve the experience but are not real security.
  • Secure checks verify the session properly (and, for database sessions, against the database) right next to the data.

The recommended structure for secure checks is a Data Access Layer (DAL): one server-only module that every data request goes through, starting with a verifySession() function:

JavaScript
// lib/dal.js
import "server-only";
import { cache } from "react";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { decrypt } from "@/lib/crypto";

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

export async function getMyMarks() {
  const { userId } = await verifySession();
  const rows = await db.query("SELECT subject, score FROM marks WHERE student_id = ?", [userId]);
  return rows.map((r) => ({ subject: r.subject, score: r.score })); // only what the page needs
}

Because every data function calls verifySession(), a page cannot forget the check. React's cache avoids repeating the work several times during one render. The last line is a Data Transfer Object (DTO) idea: return only the fields the UI needs, never whole rows with password hashes or phone numbers.

Three more rules from the docs:

  • Do not rely on layouts for auth. Layouts are not re-rendered on navigation, and they do not stop nested pages or Server Actions from running. Check in the page, in leaf components, and in the DAL.
  • Check inside every Server Action and Route Handler. They are public endpoints, reachable by direct requests.
  • Client Components cannot import the DAL. Run the check in a Server Component and pass the safe data down as props.

Logging out

JavaScript
// app/actions/auth.js
"use server";
import { redirect } from "next/navigation";
import { deleteSession } from "@/lib/session";

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

Common mistakes

  • Storing passwords or tokens in localStorage. Any script on the page can read it. Use httpOnly cookies.
  • Unsigned cookies such as role=admin. Users can edit cookies; always sign or encrypt session data.
  • Only protecting pages, not actions. Hiding the "Delete" button does nothing if the action itself does not check the role.
  • Putting personal data in the session cookie. Store only the minimum (user ID, role, expiry); fetch the rest on the server.

Key points

  • Auth has three parts: authentication, session management and authorisation; prefer a maintained auth library.
  • Log in with a Server Action that validates input and compares a password hash.
  • Store sessions in signed or encrypted httpOnly, secure, sameSite cookies (stateless) or a sessions table.
  • Use Proxy only for fast optimistic checks; put secure checks in a server-only Data Access Layer with verifySession().
  • Check permissions in every Server Action and Route Handler, and return only the fields the UI needs.

Exercise

A school portal has teachers and students. Write a DAL function getClassMarks(classId) that only a teacher may call (students get redirected to /dashboard), returning each student's name and total marks but not their phone number. Then write a Server Action updateMark(formData) that also verifies the teacher role before updating.

Show solution
JavaScript
// lib/dal.js (continued)
import { redirect } from "next/navigation";

export async function getClassMarks(classId) {
  const { role } = await verifySession();
  if (role !== "teacher") redirect("/dashboard");
  const rows = await db.query(
    "SELECT s.name, SUM(m.score) AS total, s.phone FROM students s JOIN marks m ON m.student_id = s.id WHERE s.class_id = ? GROUP BY s.id",
    [classId]
  );
  return rows.map((r) => ({ name: r.name, total: Number(r.total) })); // phone is dropped
}

// app/marks/actions.js
"use server";
import { updateTag } from "next/cache";
import { verifySession } from "@/lib/dal";

export async function updateMark(formData) {
  const { role } = await verifySession();
  if (role !== "teacher") throw new Error("Not allowed");
  const markId = Number(formData.get("markId"));
  const score = Number(formData.get("score"));
  if (!Number.isInteger(score) || score < 0 || score > 100) return;
  await db.query("UPDATE marks SET score = ? WHERE id = ?", [score, markId]);
  updateTag("marks");
}

Both functions verify the role themselves, so it does not matter which page or form calls them. Mapping rows to { name, total } keeps the phone number on the server.

Quick Quiz

1. Which part of auth decides that a student cannot edit marks?

2. Why should the session cookie be httpOnly?

3. What is the recommended role of Proxy in auth?

4. Why is checking auth only in a shared layout risky?

Learn Next.js with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call