<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged sessions</title>
<link>https://coderlegion.com/tag/sessions</link>
<description>Powered by Question2Answer</description>
<item>
<title>What Is Authentication? A Beginner's Guide (2026)</title>
<link>https://coderlegion.com/25969/what-is-authentication-a-beginners-guide-2026</link>
<description>&lt;p&gt;Every time you type your email and password into a login form, something is quietly deciding whether you actually get in. That decision — proving you are who you say you are — is called &lt;strong&gt;authentication&lt;/strong&gt;, and it's one of those concepts every developer touches constantly but rarely stops to explain from scratch.&lt;/p&gt;
&lt;p&gt;If you've ever wondered what actually happens between &quot;click Sign In&quot; and &quot;welcome back,&quot; or mixed up &lt;em&gt;authentication&lt;/em&gt; with &lt;em&gt;authorization&lt;/em&gt; in an interview, this guide is for you. By the end, you'll know exactly what authentication is, how the major methods work under the hood, and how to build a basic authentication flow yourself.&lt;/p&gt;
&lt;h3 id=&quot;what-is-authentication-quick-answer-&quot;&gt;What Is Authentication? (Quick Answer)&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Authentication is the process of verifying that someone is who they claim to be.&lt;/strong&gt; It answers one question: &lt;em&gt;&quot;Who are you?&quot;&lt;/em&gt; This usually happens by checking a password, a code from your phone, a fingerprint, or a token — something only the real user should have or know.&lt;/p&gt;
&lt;p&gt;Authentication is different from &lt;strong&gt;authorization&lt;/strong&gt;, which answers a separate question: &lt;em&gt;&quot;What are you allowed to do?&quot;&lt;/em&gt; A logged-in user is authenticated. Whether that user can delete another person's account is authorization. Mixing these two up is one of the most common beginner mistakes — keep them mentally separate from the start.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Simple way to remember it:&lt;/strong&gt; Authentication = ID check at the door. Authorization = the guest list that decides which rooms you can enter once you're inside.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;why-authentication-matters&quot;&gt;Why Authentication Matters&lt;/h3&gt;
&lt;p&gt;Without proper authentication and authorization, an app that stores personal data could expose private information to unauthorized users. Authentication provides the identity context that many authorization, personalization, and auditing systems rely on.&lt;/p&gt;
&lt;p&gt;It matters for a few concrete reasons:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Privacy&lt;/strong&gt; — only the right person sees their own data&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Security&lt;/strong&gt; — prevents account takeover and unauthorized access&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Personalization&lt;/strong&gt; — apps can remember who you are across visits&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Accountability&lt;/strong&gt; — actions can be traced back to a real identity (important for admin tools, financial apps, audit logs)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;authentication-vs-authorization-the-difference-that-trips-everyone-up&quot;&gt;Authentication vs. Authorization: The Difference That Trips Everyone Up&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;Authentication &lt;/th&gt;&lt;th&gt;Authorization&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Question it answers &lt;/td&gt;&lt;td&gt;Who are you? &lt;/td&gt;&lt;td&gt;What can you do?&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Happens &lt;/td&gt;&lt;td&gt;First &lt;/td&gt;&lt;td&gt;After authentication&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Example &lt;/td&gt;&lt;td&gt;Logging in with a password &lt;/td&gt;&lt;td&gt;Only admins can access &lt;code&gt;/admin&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Failure result &lt;/td&gt;&lt;td&gt;401 Unauthorized &lt;/td&gt;&lt;td&gt;403 Forbidden&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;Why the HTTP status codes are confusingly named:&lt;/strong&gt; &lt;code&gt;401 Unauthorized&lt;/code&gt; actually means &quot;you're not authenticated&quot; (log in first), while &lt;code&gt;403 Forbidden&lt;/code&gt; means &quot;you're authenticated, but not allowed to do this.&quot; Yes, &lt;code&gt;401&lt;/code&gt; should really be called &quot;Unauthenticated&quot; — it's a long-standing naming quirk in the HTTP spec that trips up almost every developer at least once.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/role-based-authentication-nextjs-16&quot;&gt;Related: Role-Based Access Control (RBAC) in Next.js 16&lt;/a&gt; goes deep on the authorization half of this once you're ready for it.&lt;/p&gt;
&lt;h3 id=&quot;how-authentication-works-step-by-step-&quot;&gt;How Authentication Works (Step by Step)&lt;/h3&gt;
&lt;p&gt;At a high level, almost every authentication system follows the same basic loop:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;The user provides credentials&lt;/strong&gt; — a password, a fingerprint, a one-time code, or an existing account from another provider (like signing in with Google)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The server verifies those credentials&lt;/strong&gt; — checking a hashed password, validating a signed token, or confirming a biometric match&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The server issues proof of identity&lt;/strong&gt; — usually a session or a token, stored in a cookie or sent back to the client&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;That proof is sent with future requests&lt;/strong&gt; — so the user doesn't have to log in again on every page&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;User submits credentials
        ↓
Server verifies credentials
        ↓
Server issues session/token
        ↓
Client stores it (cookie)
        ↓
Future requests include it
        ↓
Server verifies it's still valid
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That last step — verifying the proof of identity on every request — is what keeps a user &quot;logged in&quot; without asking for their password on every single page.&lt;/p&gt;
&lt;h3 id=&quot;the-main-types-of-authentication&quot;&gt;The Main Types of Authentication&lt;/h3&gt;
&lt;p&gt;There isn't just one way to authenticate someone. Here are the methods you'll actually run into as a developer.&lt;/p&gt;
&lt;h4 id=&quot;1-password-based-authentication&quot;&gt;1. Password-Based Authentication&lt;/h4&gt;
&lt;p&gt;The classic: a username/email plus a password, checked against a hashed value stored in your database. Never store plain-text passwords — always hash them with a slow, password-specific hashing algorithm such as &lt;strong&gt;Argon2id&lt;/strong&gt;. If it's not available, &lt;strong&gt;bcrypt&lt;/strong&gt; or another appropriate password hashing algorithm can be used.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/auth/hash-password.ts
import bcrypt from &quot;bcryptjs&quot;;

const SALT_ROUNDS = 12;

export async function hashPassword(plainPassword: string): Promise&amp;lt;string&amp;gt; {
  return bcrypt.hash(plainPassword, SALT_ROUNDS);
}

export async function verifyPassword(
  plainPassword: string,
  hashedPassword: string
): Promise&amp;lt;boolean&amp;gt; {
  return bcrypt.compare(plainPassword, hashedPassword);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;bcrypt.hash()&lt;/code&gt; doesn't just scramble the password — it applies a slow, computationally expensive algorithm on purpose, which increases the cost of each password guess and makes large-scale brute-force attacks significantly more expensive. &lt;code&gt;SALT_ROUNDS = 12&lt;/code&gt; is above OWASP's minimum bcrypt work factor of 10, but the right value depends on your server's hardware and traffic. Benchmark your production environment and choose a cost that makes password verification sufficiently expensive without creating unacceptable latency.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;bcrypt limitation:&lt;/strong&gt; bcrypt has a hard 72-byte input limit, and most implementations (including &lt;code&gt;bcryptjs&lt;/code&gt;) silently truncate anything past it rather than erroring. Because UTF-8 characters like emoji or accented letters can take up to 4 bytes each, a password that looks well under the limit in &lt;em&gt;characters&lt;/em&gt; can still get silently cut short in &lt;em&gt;bytes&lt;/em&gt; — which can cause two different passwords to be treated as equivalent after truncation.&lt;br&gt;
OWASP currently recommends enforcing a maximum of 72 bytes (or less, depending on the implementation) when bcrypt is used. Because UTF-8 characters can occupy multiple bytes, a character-count limit alone doesn't necessarily correspond to bcrypt's byte limit.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Comparing passwords with a plain &lt;code&gt;===&lt;/code&gt; check after &quot;encrypting&quot; them with something reversible like Base64. That's encoding, not hashing — it can be reversed instantly. Only use a dedicated password-hashing library.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;2-session-based-authentication&quot;&gt;2. Session-Based Authentication&lt;/h4&gt;
&lt;p&gt;After a successful login, the server creates a &lt;strong&gt;session&lt;/strong&gt; record stored in server-side session storage such as a database or shared cache — and gives the browser a random session ID in an &lt;code&gt;httpOnly&lt;/code&gt; cookie. On every request, the server looks up that ID to know who's logged in.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/auth/session.ts
import { cookies } from &quot;next/headers&quot;;
import { randomUUID } from &quot;crypto&quot;;

export async function createSession(userId: string, expiresAt: Date) {
  // Generate a random, unguessable session ID — never store the raw
  // userId (or anything else predictable) directly as the cookie's
  // value. The cookie is a reference you look up server-side; it
  // should never double as proof of identity on its own.
  const sessionId = randomUUID();

  // Persist the mapping server-side, e.g.:
  // await db.session.create({ data: { sessionId, userId, expiresAt } });

  const cookieStore = await cookies();

  cookieStore.set(&quot;session_id&quot;, sessionId, {
    httpOnly: true,
    secure: true,
    sameSite: &quot;lax&quot;,
    expires: expiresAt,
    path: &quot;/&quot;,
  });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Why each flag matters:&lt;/strong&gt; &lt;code&gt;httpOnly&lt;/code&gt; stops client-side JavaScript from reading the cookie (blocking a common XSS attack path). &lt;code&gt;secure&lt;/code&gt; means it's only sent over HTTPS. &lt;code&gt;sameSite: &quot;lax&quot;&lt;/code&gt; limits when the browser sends the cookie in cross-site requests and provides some CSRF protection. For state-changing operations, you should still implement an appropriate CSRF defense rather than relying on SameSite alone.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt; straightforward server-side revocation; deleting a session record can invalidate that session immediately, and deleting all of a user's sessions can log them out everywhere.&lt;br&gt;
&lt;strong&gt;Cons:&lt;/strong&gt; can require a server-side session lookup on authenticated requests, although caching can reduce the cost.&lt;/p&gt;
&lt;h4 id=&quot;3-token-based-authentication-jwt-&quot;&gt;3. Token-Based Authentication (JWT)&lt;/h4&gt;
&lt;p&gt;Instead of looking anything up server-side, a &lt;strong&gt;JSON Web Token (JWT)&lt;/strong&gt; packs the user's identity directly into a signed token. In a stateless JWT design, the server can verify the token cryptographically without looking up a session record in the database.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/auth/jwt.ts
import { SignJWT, jwtVerify } from &quot;jose&quot;;

const secretKey = new TextEncoder().encode(process.env.AUTH_SECRET);

export async function signToken(userId: string, expiresAt: Date) {
  return new SignJWT({})
    .setProtectedHeader({ alg: &quot;HS256&quot; })
    .setSubject(userId)
    .setIssuedAt()
    .setExpirationTime(expiresAt)
    .sign(secretKey);
}

export async function verifyToken(token: string) {
  try {
    const { payload } = await jwtVerify(token, secretKey, {
      algorithms: [&quot;HS256&quot;],
    });
    return payload;
  } catch {
    return null; // invalid or expired
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;jose&lt;/code&gt; is the standard, actively maintained library for signing and verifying JWTs using the Web Crypto API — it works in Node.js, edge runtimes, and browsers. &lt;code&gt;setSubject(userId)&lt;/code&gt; stores the user's ID in the token's standard &lt;code&gt;sub&lt;/code&gt; claim. &lt;code&gt;jwtVerify()&lt;/code&gt; automatically rejects the token if the signature doesn't match or if it's expired, so you never write manual expiry checks.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Signing is not encryption.&lt;/strong&gt; A JWT's payload is only base64url-encoded, not encrypted — anyone can decode and read it. The signature only proves it wasn't tampered with. Never put sensitive data (passwords, secrets) inside a JWT payload.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt; no database lookup needed to verify identity, works well across distributed/stateless servers.&lt;br&gt;
&lt;strong&gt;Cons:&lt;/strong&gt; harder to instantly revoke a single token before it expires (it's valid until it naturally expires unless you build extra machinery around it).&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 For a deeper side-by-side of these two models — including how each behaves at scale — see &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/session-vs-jwt-for-nextjs-16&quot;&gt;Session vs JWT: Which Should You Use in Next.js 16?&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;4-oauth-openid-connect-social-login-&quot;&gt;4. OAuth / OpenID Connect (Social Login)&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;OAuth 2.0&lt;/strong&gt; is an authorization framework that lets an application obtain limited access to resources on behalf of a user. It doesn't, by itself, define how an application verifies the user's identity.&lt;/p&gt;
&lt;p&gt;For &quot;Sign in with Google,&quot; &quot;Sign in with Microsoft,&quot; and similar login flows, applications commonly use &lt;strong&gt;OpenID Connect (OIDC)&lt;/strong&gt; — an identity layer built on top of OAuth 2.0. The identity provider authenticates the user and returns an &lt;strong&gt;ID token&lt;/strong&gt; containing claims about that authentication.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;User clicks &quot;Sign in with GitHub&quot;
        ↓
Redirected to GitHub
        ↓
User authenticates and approves access
        ↓
GitHub redirects back with a code
        ↓
Your server exchanges the code for tokens
        ↓
Your server validates the OIDC identity information
        ↓
Your app creates its own session
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is why OAuth is so popular for beginners and production apps alike — you outsource the hardest parts (password storage, breach risk, 2FA) to a provider that already does it well.&lt;/p&gt;
&lt;h4 id=&quot;5-multi-factor-authentication-mfa-2fa-&quot;&gt;5. Multi-Factor Authentication (MFA / 2FA)&lt;/h4&gt;
&lt;p&gt;MFA requires &lt;strong&gt;more than one&lt;/strong&gt; proof of identity — typically &quot;something you know&quot; (a password) plus &quot;something you have&quot; (a phone, an authenticator app code, a hardware key). Even if a password leaks, the attacker still can't get in without the second factor.&lt;/p&gt;
&lt;h4 id=&quot;6-passwordless-authentication-magic-links-amp-passkeys-&quot;&gt;6. Passwordless Authentication (Magic Links &amp;amp; Passkeys)&lt;/h4&gt;
&lt;p&gt;Two increasingly common alternatives to passwords:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Magic links&lt;/strong&gt; — the user enters their email, gets a one-time sign-in link, and clicking it logs them in. No password to remember or leak.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Passkeys (WebAuthn)&lt;/strong&gt; — device-based authentication using Face ID, Windows Hello, or a hardware security key. No shared secret exists to steal in the first place, which makes passkeys resistant to phishing in a way passwords fundamentally aren't.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;a-minimal-authentication-flow-in-next-js&quot;&gt;A Minimal Authentication Flow in Next.js&lt;/h3&gt;
&lt;p&gt;Here's a simplified but realistic login flow using &lt;strong&gt;Next.js 16&lt;/strong&gt;, &lt;strong&gt;TypeScript&lt;/strong&gt;, and session cookies — the same core pattern used by most production apps, just without the database wiring.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// actions/login.ts
&quot;use server&quot;;

import { redirect } from &quot;next/navigation&quot;;
import { verifyPassword } from &quot;@/lib/auth/hash-password&quot;;
import { createSession } from &quot;@/lib/auth/session&quot;;
import { z } from &quot;zod&quot;;

const loginSchema = z.object({
  email: z.email(),
  password: z.string().min(8),
});

export async function login(prevState: unknown, formData: FormData) {
  const parsed = loginSchema.safeParse({
    email: formData.get(&quot;email&quot;),
    password: formData.get(&quot;password&quot;),
  });

  if (!parsed.success) {
    return { error: &quot;Enter a valid email and password.&quot; };
  }

  // Look up the user in your database (pseudocode)
  const user = await db.user.findUnique({ where: { email: parsed.data.email } });

  if (!user || !(await verifyPassword(parsed.data.password, user.passwordHash))) {
    // Same generic error for both cases — don't reveal which one was wrong
    return { error: &quot;Invalid email or password.&quot; };
  }

  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
  await createSession(user.id, expiresAt);

  redirect(&quot;/dashboard&quot;);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; the form data is validated with Zod before touching the database — never trust raw input. Notice the login failure returns the &lt;strong&gt;same generic message&lt;/strong&gt; whether the email doesn't exist or the password is wrong; revealing which one failed makes it easier for attackers to enumerate valid accounts. A working session gets created only after the password check succeeds, then the user is redirected.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Checking authentication only in proxy and assuming that's enough. Proxy-level checks are a good UX shortcut (fast redirect before a page even loads), but sensitive Server Actions, Route Handlers, and data-access functions should independently verify authentication and authorization rather than relying solely on &lt;code&gt;proxy.ts&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Building this by hand is a great way to learn, but most production apps reach for a maintained library instead — see &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/how-to-add-nextauth-in-nextjs-16&quot;&gt;How to Add Auth.js (NextAuth v5) in Next.js 16&lt;/a&gt;, &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/how-to-add-clerk-authentication-in-nextjs-16&quot;&gt;Add Clerk Authentication to Next.js 16&lt;/a&gt;, or (if you're already on Supabase) &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/supabase-auth-vs-firebase-auth&quot;&gt;Supabase Auth vs Firebase Auth&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id=&quot;common-authentication-mistakes-to-avoid&quot;&gt;Common Authentication Mistakes to Avoid&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Storing plain-text passwords&lt;/strong&gt; — always hash with Argon2id or bcrypt, never store or log the raw password&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Storing a predictable value as the session cookie&lt;/strong&gt; — the cookie should be a random, unguessable reference, never the raw user ID or anything else an attacker could construct&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Weak or reused session/JWT secrets&lt;/strong&gt; — generate a long, random secret and never commit it to git&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Revealing which field was wrong&lt;/strong&gt; — &quot;Invalid email&quot; vs. &quot;Invalid password&quot; tells attackers an account exists&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Skipping rate limiting&lt;/strong&gt; — without it, attackers can brute-force passwords with unlimited attempts&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Relying only on client-side checks&lt;/strong&gt; — a hidden button or disabled form field doesn't stop a direct API request&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Forgetting to expire sessions/tokens&lt;/strong&gt; — every session and token should have a sane expiry, not live forever&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;frequently-asked-questions&quot;&gt;Frequently Asked Questions&lt;/h3&gt;
&lt;h4 id=&quot;what-is-the-difference-between-authentication-and-authorization-&quot;&gt;What is the difference between authentication and authorization?&lt;/h4&gt;
&lt;p&gt;Authentication confirms &lt;em&gt;who&lt;/em&gt; someone is (logging in). Authorization decides &lt;em&gt;what&lt;/em&gt; they're allowed to do once they're recognized (permissions, roles, access control). You always need authentication before authorization can happen.&lt;/p&gt;
&lt;h4 id=&quot;is-a-login-system-the-same-thing-as-authentication-&quot;&gt;Is a login system the same thing as authentication?&lt;/h4&gt;
&lt;p&gt;Yes — a login system is simply the implementation of authentication. Whatever mechanism confirms a user's identity (password, OAuth, passkey) is functioning as authentication, regardless of what you call the feature.&lt;/p&gt;
&lt;h4 id=&quot;what-s-the-difference-between-sessions-and-jwts-&quot;&gt;What's the difference between sessions and JWTs?&lt;/h4&gt;
&lt;p&gt;A session stores authentication/session state on the server and gives the browser only a random reference ID, which allows instant revocation. A JWT stores the identity data directly inside a signed token the server can verify without a database lookup, trading easy revocation for less server-side state.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Keep JWT minimal — a JWT should not be treated as a general-purpose user profile or database replacement.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;do-i-need-a-library-to-build-authentication-or-can-i-build-it-myself-&quot;&gt;Do I need a library to build authentication, or can I build it myself?&lt;/h4&gt;
&lt;p&gt;You can build basic password + session authentication yourself using the patterns above, but for anything production-facing, an established library (&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/how-to-add-nextauth-in-nextjs-16&quot;&gt;Auth.js&lt;/a&gt;, &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/how-to-add-clerk-authentication-in-nextjs-16&quot;&gt;Clerk&lt;/a&gt;, &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/authjs-vs-better-auth&quot;&gt;Better Auth&lt;/a&gt;, or your database provider's built-in auth) is strongly recommended — they handle edge cases like token rotation, breach detection, and secure defaults that are easy to get subtly wrong by hand.&lt;/p&gt;
&lt;h4 id=&quot;is-two-factor-authentication-2fa-actually-necessary-&quot;&gt;Is two-factor authentication (2FA) actually necessary?&lt;/h4&gt;
&lt;p&gt;For anything holding sensitive data — financial info, personal messages, admin access — yes. 2FA dramatically reduces account takeover risk even when a password leaks, since the attacker also needs the second factor.&lt;/p&gt;
&lt;h4 id=&quot;are-passkeys-better-than-passwords-&quot;&gt;Are passkeys better than passwords?&lt;/h4&gt;
&lt;p&gt;Generally yes, from a security standpoint — passkeys are phishing-resistant because there's no shared secret to trick someone into revealing. Adoption is still growing, though, so most apps currently offer passkeys as an &lt;em&gt;option&lt;/em&gt; alongside passwords rather than a full replacement.&lt;/p&gt;
&lt;h3 id=&quot;wrapping-up&quot;&gt;Wrapping Up&lt;/h3&gt;
&lt;p&gt;Authentication boils down to one question — &quot;who are you?&quot; — answered through a password, a token, a device, or some combination of them. Once you separate authentication from authorization, the ecosystem becomes easier to understand: passwords and passkeys are authentication mechanisms, sessions and JWTs are ways to maintain authenticated state, and OAuth/OIDC provide delegated authorization and federated identity.&lt;/p&gt;
&lt;p&gt;If you're building this in a real Next.js app, the next logical steps are wiring up role-based authorization on top of your authentication layer, and deciding between session-based and JWT-based auth based on whether instant revocation matters for your product.&lt;/p&gt;
&lt;h3 id=&quot;continue-learning&quot;&gt;Continue Learning&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/session-vs-jwt-for-nextjs-16&quot;&gt;Session vs JWT: Which Should You Use in Next.js 16?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/role-based-authentication-nextjs-16&quot;&gt;Role-Based Access Control (RBAC) in Next.js 16 (2026 Beginner's Guide)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/authjs-vs-better-auth&quot;&gt;Auth.js vs Better Auth in 2026: Which Should You Use?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;/https://devstacked.tech/blog/clerk-vs-nextauth-2026&quot;&gt;NextAuth (Auth.js) vs Clerk in 2026: Compared&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/supabase-auth-vs-firebase-auth&quot;&gt;Supabase Auth vs Firebase Auth in 2026&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;free-developer-tools&quot;&gt;Free Developer Tools&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/tools/zod-schema-generator&quot;&gt;Zod Schema Generator&lt;/a&gt; — generate a validated schema for your role/permission payloads straight from a JSON sample or TypeScript interface, including a ready-to-use Server Action.&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/tools/rls-policy-generator&quot;&gt;Supabase RLS Policy Generator&lt;/a&gt; — pair this guide's application-level RBAC with database-level Row Level Security. Generates owner-only, team, and admin-override policies with the &lt;code&gt;auth.uid()&lt;/code&gt; performance wrapper built in.&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/25969/what-is-authentication-a-beginners-guide-2026</guid>
<pubDate>Tue, 01 Sep 2026 05:07:56 +0000</pubDate>
</item>
<item>
<title>Session vs JWT: Which Should You Use in Next.js 16?</title>
<link>https://coderlegion.com/25323/session-vs-jwt-which-should-you-use-in-next-js-16</link>
<description>&lt;p&gt;You're building a login flow in Next.js 16, and you hit the question every auth tutorial glosses over: should the logged-in state live in a &lt;strong&gt;session&lt;/strong&gt; or a &lt;strong&gt;JWT&lt;/strong&gt;? Half the guides you find use one, half use the other, and almost none of them tell you &lt;em&gt;why&lt;/em&gt; — or what breaks if you pick wrong.&lt;/p&gt;
&lt;p&gt;Here's the annoying part: both approaches look nearly identical from the outside. A cookie gets set, the user stays logged in, everyone's happy. The difference only shows up later — when you need to log a user out of every device instantly, when your app scales past one server, or when you're debugging why a &quot;revoked&quot; user can still hit your API for another six days.&lt;/p&gt;
&lt;p&gt;By the end of this guide, you'll know exactly how sessions and JWTs work under the hood, see real, working code for both in Next.js 16 — using &lt;code&gt;jose&lt;/code&gt;, Server Actions, and &lt;code&gt;proxy.ts&lt;/code&gt; — and know which one actually fits your project instead of copying whatever the last tutorial you read happened to use.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-this-is-tricky&quot;&gt;Why This Is Tricky&lt;/h3&gt;
&lt;p&gt;Most explanations of &quot;session vs JWT&quot; get stuck comparing them as if they're two totally separate technologies. They're not. In both approaches, the browser stores a cookie. The real difference is &lt;strong&gt;what that cookie contains, and where the source of truth lives&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Session-based auth&lt;/strong&gt;: the cookie holds a random ID. The actual user data lives in your database. Every request that needs to know &quot;who is this?&quot; looks it up server-side.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;JWT-based auth&lt;/strong&gt;: the cookie holds the user data itself, cryptographically signed. Your server can verify it's untampered without ever touching a database.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That one distinction — &lt;em&gt;lookup vs. self-contained&lt;/em&gt; — is what drives every tradeoff in this post: revocation, scaling, latency, and how much you need to trust the cookie itself.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; If you've used &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/how-to-add-nextauth-in-nextjs-16&quot;&gt;Auth.js (NextAuth v5)&lt;/a&gt;, you've already used both without necessarily noticing — its &lt;code&gt;session: { strategy: &quot;jwt&quot; }&lt;/code&gt; and &lt;code&gt;session: { strategy: &quot;database&quot; }&lt;/code&gt; options are exactly this same choice, just configured for you.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;prerequisites&quot;&gt;Prerequisites&lt;/h3&gt;
&lt;p&gt;This guide uses:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Next.js 16&lt;/strong&gt; with the App Router&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TypeScript&lt;/strong&gt; in strict mode&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;React Compiler&lt;/strong&gt; enabled (no manual &lt;code&gt;useMemo&lt;/code&gt;/&lt;code&gt;useCallback&lt;/code&gt; needed)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tailwind CSS&lt;/strong&gt; for the (minimal) UI examples&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You don't need a real database to follow along — the JWT section has zero dependencies, and the session section uses a generic &lt;code&gt;db&lt;/code&gt; client you can swap for Postgres, Supabase, or whatever you're already using.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;how-sessions-work&quot;&gt;How Sessions Work&lt;/h3&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Think of it like:&lt;/strong&gt; a coat check ticket. The ticket itself is just a number — it means nothing on its own. The actual coat (your user data) stays with the attendant (your database). Lose the ticket, and the attendant can also just... stop honoring it. Instantly.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;ol&gt;
&lt;li&gt;User logs in with the right credentials.&lt;/li&gt;
&lt;li&gt;Your server generates a random token, stores it in a &lt;code&gt;sessions&lt;/code&gt; table alongside the user's ID and an expiry, and sends that token back as an &lt;code&gt;httpOnly&lt;/code&gt; cookie.&lt;/li&gt;
&lt;li&gt;On every request, your server takes the cookie value, looks it up in the database, and either finds a valid session or doesn't.&lt;/li&gt;
&lt;li&gt;To log someone out — including forcibly, from your side — you delete the row. Done. The cookie is now worthless.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;The tradeoff:&lt;/strong&gt; every authenticated request costs a database round trip (unless you cache it), but you get instant, guaranteed revocation.&lt;/p&gt;
&lt;h3 id=&quot;how-jwts-work&quot;&gt;How JWTs Work&lt;/h3&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Think of it like:&lt;/strong&gt; a sealed, signed letter. Anyone can read what's inside once it's opened, but nobody can forge your signature or quietly edit the contents without breaking the seal. The letter is self-sufficient — you don't need to call anyone to confirm it's real, just check the seal.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;ol&gt;
&lt;li&gt;User logs in with the right credentials.&lt;/li&gt;
&lt;li&gt;Your server creates a token containing the user's ID (and whatever else you want) and &lt;strong&gt;signs&lt;/strong&gt; it with a secret key.&lt;/li&gt;
&lt;li&gt;On every request, your server checks the signature against that same secret. If it matches, the data inside is trusted — no database lookup required.&lt;/li&gt;
&lt;li&gt;To log someone out, you delete the cookie client-side. But if someone already has a copy of that token, it's still valid — and still verifiable — until it naturally expires. There's no built-in way to say &quot;actually, ignore this one.&quot;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;The tradeoff:&lt;/strong&gt; verification is fast and needs no database, but you give up the ability to instantly kill a single session without extra machinery.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Calling a JWT stored in a cookie a &quot;session&quot; and assuming it behaves like one. It doesn't — a JWT has no server-side record at all unless you deliberately build one (see the revocation section below). If your mental model is &quot;session,&quot; you'll be surprised the first time you try to force-logout a user and nothing happens.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Cookie size limits:&lt;/strong&gt; browsers cap a single cookie at roughly 4KB, and most reverse proxies (Nginx, Cloudflare) reject requests once the &lt;em&gt;total&lt;/em&gt; header size gets much past 8KB. A session-ID cookie is always tiny — it's just a random string. A JWT cookie grows with every claim you cram into the payload (roles, permissions, profile fields). Stuff enough into it and the cookie silently fails to set, or your CDN starts 400-ing requests, with no obvious error pointing back at &quot;your JWT got too big.&quot; Keep JWT payloads minimal — an ID and maybe a role, not a serialized user object.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-1-set-up-the-shared-pieces&quot;&gt;Step 1: Set Up the Shared Pieces&lt;/h3&gt;
&lt;p&gt;Both approaches need somewhere to keep a signing secret. Add one to your environment file:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# .env.local
SESSION_SECRET=your-long-random-secret-here
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Generate a strong value instead of typing something memorable:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;node -e &quot;console.log(crypto.randomBytes(32).toString('hex'))&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Committing &lt;code&gt;SESSION_SECRET&lt;/code&gt; to git, or reusing your &lt;code&gt;.env.local&lt;/code&gt; value in production. Treat it exactly like a database password — if it leaks, an attacker can forge valid sessions for any user, for either approach below.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-2-build-jwt-based-auth-with-jose&quot;&gt;Step 2: Build JWT-Based Auth with &lt;code&gt;jose&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;jose&lt;/code&gt; is the standard library for signing and verifying JWTs in an Edge-compatible way — it uses the Web Crypto API instead of Node's &lt;code&gt;crypto&lt;/code&gt; module, so it works identically in Server Actions, Route Handlers, and &lt;code&gt;proxy.ts&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install jose
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;create-the-sign-verify-helpers&quot;&gt;Create the sign/verify helpers&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/jwt/session.ts
import &quot;server-only&quot;;
import { SignJWT, jwtVerify } from &quot;jose&quot;;

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

export async function encrypt(userId: string, expiresAt: Date) {
  return new SignJWT({})
    .setProtectedHeader({ alg: &quot;HS256&quot; })
    .setSubject(userId) // sub — who this token belongs to
    .setIssuedAt() // iat — when it was issued
    .setExpirationTime(expiresAt) // exp — when it stops being valid
    .sign(encodedKey);
}

export async function decrypt(token: string | undefined = &quot;&quot;) {
  try {
    const { payload } = await jwtVerify(token, encodedKey, {
      algorithms: [&quot;HS256&quot;],
    });
    return payload; // payload.sub is the userId; iat/exp are unix seconds
  } catch {
    // Invalid signature, tampered payload, or expired token
    return null;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; instead of a custom &lt;code&gt;userId&lt;/code&gt; field, this uses the standard, registered JWT claims — &lt;code&gt;sub&lt;/code&gt; (subject, via &lt;code&gt;.setSubject()&lt;/code&gt;), &lt;code&gt;iat&lt;/code&gt; (issued-at, via &lt;code&gt;.setIssuedAt()&lt;/code&gt;), and &lt;code&gt;exp&lt;/code&gt; (expiry, via &lt;code&gt;.setExpirationTime()&lt;/code&gt;). These three are part of the &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.rfc-editor.org/rfc/rfc7519#section-4.1&quot;&gt;JWT spec itself&lt;/a&gt;, not a &lt;code&gt;jose&lt;/code&gt;-specific convention, so any JWT library — in any language — knows how to read them. &lt;code&gt;jwtVerify&lt;/code&gt; automatically checks &lt;code&gt;exp&lt;/code&gt; and rejects an expired token before your code ever sees the payload, so you never write manual date comparisons.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Signing is not encryption.&lt;/strong&gt; &lt;code&gt;SignJWT&lt;/code&gt; — despite this file calling the wrapper functions &lt;code&gt;encrypt&lt;/code&gt;/&lt;code&gt;decrypt&lt;/code&gt; for readability — does &lt;strong&gt;not&lt;/strong&gt; hide the payload. A JWT's contents are only base64url-&lt;em&gt;encoded&lt;/em&gt;, not encrypted, so anyone who intercepts the cookie (or just opens DevTools → Application → Cookies) can decode and read &lt;code&gt;sub&lt;/code&gt;, &lt;code&gt;iat&lt;/code&gt;, and &lt;code&gt;exp&lt;/code&gt; in plain text. What the signature guarantees is that the payload &lt;strong&gt;wasn't tampered with&lt;/strong&gt; — change one character and &lt;code&gt;jwtVerify&lt;/code&gt; rejects it. Never put anything genuinely secret (passwords, tokens for other services, PII you wouldn't want exposed) inside a signed JWT. If you actually need to hide the contents too, that's a different primitive — JWE — which &lt;code&gt;jose&lt;/code&gt; supports separately via &lt;code&gt;EncryptJWT&lt;/code&gt;/&lt;code&gt;jwtDecrypt&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Do you still need &lt;code&gt;setExpirationTime&lt;/code&gt; if you also track expiry elsewhere?&lt;/strong&gt; No — and the version above deliberately avoids the redundancy. Compute the expiry date &lt;strong&gt;once&lt;/strong&gt;, and hand that same &lt;code&gt;Date&lt;/code&gt; to both &lt;code&gt;setExpirationTime()&lt;/code&gt; (for the JWT's own &lt;code&gt;exp&lt;/code&gt; claim) and the cookie's &lt;code&gt;expires&lt;/code&gt; option, as shown in the next step. Two separately-computed &quot;7 days from now&quot; values can drift apart by milliseconds and give you two conflicting sources of truth for the same thing — pick one.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;create-the-session-cookie-on-login&quot;&gt;Create the session cookie on login&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/jwt/actions.ts
&quot;use server&quot;;

import { cookies } from &quot;next/headers&quot;;
import { redirect } from &quot;next/navigation&quot;;
import { encrypt } from &quot;./session&quot;;

export async function createSession(userId: string) {
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
  const session = await encrypt(userId, expiresAt); // same Date used for exp and the cookie
  const cookieStore = await cookies();

  cookieStore.set(&quot;session&quot;, session, {
    httpOnly: true,
    secure: true,
    expires: expiresAt,
    sameSite: &quot;lax&quot;,
    path: &quot;/&quot;,
  });
}

export async function deleteSession() {
  const cookieStore = await cookies();
  cookieStore.delete(&quot;session&quot;);
  redirect(&quot;/login&quot;);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;httpOnly: true&lt;/code&gt; keeps the cookie invisible to client-side JavaScript, which stops it from being stolen via an XSS attack. &lt;code&gt;secure: true&lt;/code&gt; means it's only ever sent over HTTPS. &lt;code&gt;sameSite: &quot;lax&quot;&lt;/code&gt; blocks the cookie from being attached to most cross-site requests, which is your main defense against CSRF here. None of this is JWT-specific — you'd want the exact same flags on a session-ID cookie too.&lt;/p&gt;
&lt;h4 id=&quot;verify-the-session-with-a-data-access-layer&quot;&gt;Verify the session with a Data Access Layer&lt;/h4&gt;
&lt;p&gt;Don't scatter &lt;code&gt;decrypt()&lt;/code&gt; calls across every page. Centralize verification in one cached function — this is what Next.js's own docs call a &lt;strong&gt;Data Access Layer (DAL)&lt;/strong&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/jwt/dal.ts
import &quot;server-only&quot;;
import { cache } from &quot;react&quot;;
import { cookies } from &quot;next/headers&quot;;
import { redirect } from &quot;next/navigation&quot;;
import { decrypt } from &quot;./session&quot;;

export const verifySession = cache(async () =&amp;gt; {
  const cookieStore = await cookies();
  const token = cookieStore.get(&quot;session&quot;)?.value;
  const session = await decrypt(token);

  if (!session?.sub) {
    redirect(&quot;/login&quot;);
  }

  return { isAuth: true, userId: session.sub };
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; wrapping this in React's &lt;code&gt;cache()&lt;/code&gt; means that no matter how many components on the same page call &lt;code&gt;verifySession()&lt;/code&gt;, the actual decrypt-and-check logic only runs once per request — every subsequent call reuses the result. &lt;code&gt;session.sub&lt;/code&gt; is the standard claim set by &lt;code&gt;.setSubject()&lt;/code&gt; earlier, so this reads it back out under a friendlier &lt;code&gt;userId&lt;/code&gt; name for the rest of your app. Call this at the top of any Server Component, Server Action, or Route Handler that needs to know who's logged in.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// app/dashboard/page.tsx
import { verifySession } from &quot;@/lib/jwt/dal&quot;;

export default async function DashboardPage() {
  const { userId } = await verifySession();

  return &amp;lt;h1&amp;gt;Welcome back, user {userId}&amp;lt;/h1&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;add-an-optimistic-check-in-proxy-ts&quot;&gt;Add an optimistic check in &lt;code&gt;proxy.ts&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;In Next.js 16, &lt;code&gt;middleware.ts&lt;/code&gt; was renamed to &lt;code&gt;proxy.ts&lt;/code&gt; — same file convention, same idea. It's the first thing that runs before a request reaches a page, which makes it a good place for a &lt;em&gt;fast, optimistic&lt;/em&gt; redirect. Full verification still happens in the DAL above — &lt;code&gt;proxy.ts&lt;/code&gt; just avoids letting an obviously-unauthenticated request render a protected page at all.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// proxy.ts
import { NextRequest, NextResponse } from &quot;next/server&quot;;

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

export default function proxy(req: NextRequest) {
  const path = req.nextUrl.pathname;
  const isProtectedRoute = protectedRoutes.some((route) =&amp;gt; path.startsWith(route));
  const isPublicRoute = publicRoutes.includes(path);

  // Optimistic check only — we just look for the cookie's presence.
  // We deliberately don't verify the signature here; that happens
  // in verifySession() inside the Data Access Layer.
  const cookie = req.cookies.get(&quot;session&quot;)?.value;

  if (isProtectedRoute &amp;amp;&amp;amp; !cookie) {
    return NextResponse.redirect(new URL(&quot;/login&quot;, req.nextUrl));
  }

  if (isPublicRoute &amp;amp;&amp;amp; cookie) {
    return NextResponse.redirect(new URL(&quot;/dashboard&quot;, req.nextUrl));
  }

  return NextResponse.next();
}

export const config = {
  matcher: [&quot;/((?!_next/static|_next/image|favicon.ico).*)&quot;],
};
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Treating &lt;code&gt;proxy.ts&lt;/code&gt; as your only security check. It's a UX nicety that avoids a flash of protected content — it is not a substitute for re-verifying the session inside every Server Action and Route Handler that actually touches data. A request can always be sent directly, bypassing whatever page it &quot;should&quot; have come from.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-3-build-database-backed-sessions&quot;&gt;Step 3: Build Database-Backed Sessions&lt;/h3&gt;
&lt;p&gt;Now the other approach — a real session table your server checks on each request.&lt;/p&gt;
&lt;h4 id=&quot;create-the-sessions-table&quot;&gt;Create the sessions table&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Works for Postgres/Supabase; adjust types for your database
create table sessions (
  token_hash text primary key,
  user_id uuid not null references auth.users(id) on delete cascade,
  expires_at timestamptz not null,
  created_at timestamptz not null default now()
);

create index idx_sessions_user_id on sessions (user_id);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; notice the primary key is &lt;code&gt;token_hash&lt;/code&gt;, not &lt;code&gt;token&lt;/code&gt;. We never store the raw session token in the database — only its hash. This mirrors how you'd never store a plaintext password: if your database ever leaked, an attacker with the hashes still couldn't reconstruct a working cookie.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Cleanup:&lt;/strong&gt; expiring a session only stops it from being &lt;em&gt;accepted&lt;/em&gt; — the &lt;code&gt;expires_at &amp;lt; now()&lt;/code&gt; check in &lt;code&gt;verifyDbSession&lt;/code&gt; below handles that. It does &lt;strong&gt;not&lt;/strong&gt; delete the row. Left alone, this table grows forever, one row per login, most of them long past their expiry date. Run a scheduled cleanup — a &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/tools/vercel-cron-generator&quot;&gt;Vercel Cron&lt;/a&gt; job hitting a small Route Handler once a day is enough for most apps:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;delete from sessions where expires_at &amp;lt; now();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is purely a housekeeping/performance concern (an unbounded table gets slower to index and back up over time), not a security one — an expired row is already useless for authentication either way.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;create-the-session-on-login&quot;&gt;Create the session on login&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/db-session/create.ts
import &quot;server-only&quot;;
import { randomBytes, createHash } from &quot;crypto&quot;;
import { cookies } from &quot;next/headers&quot;;
import { db } from &quot;@/lib/db&quot;;

function hashToken(token: string) {
  return createHash(&quot;sha256&quot;).update(token).digest(&quot;hex&quot;);
}

export async function createDbSession(userId: string) {
  const token = randomBytes(32).toString(&quot;hex&quot;); // goes to the browser
  const tokenHash = hashToken(token); // goes to the database
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);

  await db.session.create({
    data: { tokenHash, userId, expiresAt },
  });

  const cookieStore = await cookies();
  cookieStore.set(&quot;session&quot;, token, {
    httpOnly: true,
    secure: true,
    sameSite: &quot;lax&quot;,
    expires: expiresAt,
    path: &quot;/&quot;,
  });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;randomBytes(32)&lt;/code&gt; generates a token with enough entropy that guessing it is computationally infeasible — this is the piece doing the actual security work, not the hashing. We hash it with SHA-256 purely so a database leak doesn't hand out working session tokens; the browser still holds the real, unhashed token in its cookie.&lt;/p&gt;
&lt;h4 id=&quot;verify-the-session-with-caching-so-it-doesn-t-hit-the-database-on-every-render-&quot;&gt;Verify the session (with caching, so it doesn't hit the database on every render)&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/db-session/dal.ts
import &quot;server-only&quot;;
import { cache } from &quot;react&quot;;
import { cookies } from &quot;next/headers&quot;;
import { createHash } from &quot;crypto&quot;;
import { redirect } from &quot;next/navigation&quot;;
import { db } from &quot;@/lib/db&quot;;

function hashToken(token: string) {
  return createHash(&quot;sha256&quot;).update(token).digest(&quot;hex&quot;);
}

export const verifyDbSession = cache(async () =&amp;gt; {
  const cookieStore = await cookies();
  const token = cookieStore.get(&quot;session&quot;)?.value;

  if (!token) redirect(&quot;/login&quot;);

  const tokenHash = hashToken(token);
  const session = await db.session.findUnique({
    where: { tokenHash },
    include: { user: true },
  });

  if (!session || session.expiresAt &amp;lt; new Date()) {
    redirect(&quot;/login&quot;);
  }

  return { isAuth: true, userId: session.userId, user: session.user };
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; just like the JWT version, &lt;code&gt;cache()&lt;/code&gt; means one database lookup per request no matter how many components ask &quot;who's logged in?&quot; This is the piece that makes database sessions viable in practice — without it, a page with five components each checking auth would fire five identical queries.&lt;/p&gt;
&lt;h4 id=&quot;revoking-a-session-the-whole-point-of-this-approach&quot;&gt;Revoking a session — the whole point of this approach&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/db-session/delete.ts
&quot;use server&quot;;

import { cookies } from &quot;next/headers&quot;;
import { redirect } from &quot;next/navigation&quot;;
import { createHash } from &quot;crypto&quot;;
import { db } from &quot;@/lib/db&quot;;

function hashToken(token: string) {
  return createHash(&quot;sha256&quot;).update(token).digest(&quot;hex&quot;);
}

export async function deleteDbSession() {
  const cookieStore = await cookies();
  const token = cookieStore.get(&quot;session&quot;)?.value;

  if (token) {
    await db.session.delete({ where: { tokenHash: hashToken(token) } }).catch(() =&amp;gt; {});
  }

  cookieStore.delete(&quot;session&quot;);
  redirect(&quot;/login&quot;);
}

// Force-logout every session for a user — e.g. after a password reset
export async function revokeAllSessionsForUser(userId: string) {
  await db.session.deleteMany({ where: { userId } });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;revokeAllSessionsForUser&lt;/code&gt; is the function you can't build with plain JWTs — one query, and every device that user is logged in on is instantly signed out, because the next request they make finds no matching row. This is the single biggest practical reason teams reach for database sessions over JWTs.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;proxy.ts&lt;/code&gt; looks identical to the JWT version above — it's still just checking whether the &lt;code&gt;session&lt;/code&gt; cookie exists, since that check never needed to know which auth strategy is behind it.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;session-vs-jwt-side-by-side&quot;&gt;Session vs JWT: Side-by-Side&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;Session (database) &lt;/th&gt;&lt;th&gt;JWT&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Where the data lives&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Server-side (database row) &lt;/td&gt;&lt;td&gt;Inside the token itself&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Cookie contents&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Random, meaningless ID &lt;/td&gt;&lt;td&gt;Signed, self-contained payload&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Instant revocation&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;✅ Delete the row &lt;/td&gt;&lt;td&gt;❌ Not without extra machinery&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Force-logout everywhere&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;✅ One query &lt;/td&gt;&lt;td&gt;❌ Requires a blocklist or short expiry + refresh&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Per-request cost&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;One DB lookup (cacheable per request) &lt;/td&gt;&lt;td&gt;No DB call — just signature verification&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Works well at scale / multi-server&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Needs a shared database (usually already true) &lt;/td&gt;&lt;td&gt;Naturally stateless, nothing to share&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Payload can go stale&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;No — always reads current data &lt;/td&gt;&lt;td&gt;Yes — data inside is frozen until re-issued&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Implementation complexity&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Slightly more (schema, hashing, cleanup) &lt;/td&gt;&lt;td&gt;Slightly less (no schema needed)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Best for&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Apps where instant revocation matters &amp;lt;br/&amp;gt; (banking, admin tools, anything security-sensitive) &lt;/td&gt;&lt;td&gt;High-traffic, distributed systems where avoiding a &amp;lt;br/&amp;gt;DB hit per request matters&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; &quot;JWTs don't need a database&quot; is true for verification, but most real apps still store &lt;em&gt;something&lt;/em&gt; — a refresh-token table, a denylist, or at minimum a &lt;code&gt;passwordChangedAt&lt;/code&gt; timestamp checked against the token's &lt;code&gt;iat&lt;/code&gt; (issued-at) claim. The stateless promise of JWTs is a little softer in practice than it sounds in theory.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;a-closer-look-what-horizontal-scaling-actually-means-here&quot;&gt;A Closer Look: What &quot;Horizontal Scaling&quot; Actually Means Here&lt;/h4&gt;
&lt;p&gt;&quot;Naturally stateless&quot; in the table above is doing a lot of work, so let's unpack it. Say your app runs on five server instances behind a load balancer — a request from the same user could land on a different instance every time.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;With database sessions:&lt;/strong&gt; whichever instance handles the request just needs to reach the &lt;em&gt;same&lt;/em&gt; database everyone else does. In practice this is a non-issue for most Next.js apps, since you already have one shared database — every instance was already talking to it for everything else. The &quot;scaling problem&quot; people worry about here mostly doesn't exist unless you're deliberately running sessions in something instance-local, like in-memory storage (don't do that).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;With JWTs:&lt;/strong&gt; verification needs nothing but the signing secret, which every instance already has as an environment variable. No instance has to coordinate with any other instance, or with a database, to answer &quot;is this token valid?&quot; That's genuinely useful once you're running at the edge — Cloudflare Workers, Vercel Edge Functions — across multiple regions, where a round trip to a single-region database could add real, noticeable latency to &lt;em&gt;every&lt;/em&gt; request.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So the honest version: for a typical app with one shared Postgres/Supabase database, this isn't really a deciding factor — you're already &quot;horizontally scaled&quot; for sessions for free. It starts to matter once you're running truly distributed, multi-region, or edge-first infrastructure where a database round trip per request is a real, measured cost, not a hypothetical one.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;sliding-rolling-sessions&quot;&gt;Sliding (Rolling) Sessions&lt;/h3&gt;
&lt;p&gt;So far, every example here uses a &lt;strong&gt;fixed&lt;/strong&gt; expiry — log in, get 7 days, and after 7 days you're logged out no matter how active you were. A &lt;strong&gt;sliding session&lt;/strong&gt; (also called a rolling session) instead extends the expiry every time the user does something, so an active user effectively never gets logged out, while an idle one still expires on schedule.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;With database sessions, this is simple:&lt;/strong&gt; on every successful &lt;code&gt;verifyDbSession()&lt;/code&gt; call, update that row's &lt;code&gt;expires_at&lt;/code&gt; to &quot;now + 7 days&quot; again.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// Inside verifyDbSession, after confirming the session is valid
await db.session.update({
  where: { tokenHash },
  data: { expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) },
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One extra write per (cached) verification — cheap, and the source of truth (the row) is already being touched anyway.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; In production it's common to only extend the session once every few hours instead of on every request to avoid unnecessary writes.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;With JWTs, this is more work:&lt;/strong&gt; the &lt;code&gt;exp&lt;/code&gt; claim is baked into the signature at issue time — you can't quietly extend it without re-signing a brand-new token and setting a brand-new cookie on the response. Doable inside a Route Handler or Server Action (create a fresh token with a new &lt;code&gt;expiresAt&lt;/code&gt;, call &lt;code&gt;cookies().set()&lt;/code&gt; again), but it means writing a cookie back on requests that otherwise wouldn't need one, which is easy to forget to wire up consistently across every route.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; If &quot;stay logged in while active, expire after a week of silence&quot; is a real requirement for your app, that alone is a reasonable tie-breaker toward database sessions — it's a one-line &lt;code&gt;update&lt;/code&gt; there versus meaningfully more plumbing with JWTs.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;can-you-get-revocation-with-jwts-&quot;&gt;Can You Get Revocation With JWTs?&lt;/h3&gt;
&lt;p&gt;Yes, with a tradeoff. Two common patterns:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Short expiry + refresh tokens.&lt;/strong&gt; Issue an access token that expires in 10–15 minutes, plus a longer-lived refresh token stored in the database. To revoke, you delete the refresh token — the access token is still technically valid, but only for a few more minutes, which is an acceptable blast radius for most apps.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A denylist.&lt;/strong&gt; Keep a small, fast-to-check store (Redis is the usual choice) of token IDs (&lt;code&gt;jti&lt;/code&gt; claims) that have been explicitly revoked. Every verification also checks this list. This reintroduces a lookup on every request — at that point, you've mostly reinvented database sessions with extra steps.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Neither of these makes JWTs behave exactly like sessions — they just narrow the gap. If instant, guaranteed revocation is a hard requirement (think: a compromised admin account), database sessions remove the problem entirely instead of managing around it.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;which-one-should-you-actually-use-&quot;&gt;Which One Should You Actually Use?&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Building a typical SaaS dashboard, internal tool, or content site?&lt;/strong&gt; Database sessions. The DB hit is cheap with caching, and &quot;log this user out right now&quot; is a feature you'll eventually need, even if you don't think so today.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Building something with genuinely massive, distributed, stateless traffic&lt;/strong&gt; (think: an API consumed by thousands of independent clients, or infrastructure where adding a database round trip to every request is a real cost)? JWTs earn their keep here.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Already using &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/how-to-add-nextauth-in-nextjs-16&quot;&gt;Auth.js&lt;/a&gt; or &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/how-to-add-clerk-authentication-in-nextjs-16&quot;&gt;Clerk&lt;/a&gt;?&lt;/strong&gt; You don't have to choose by hand — Auth.js exposes &lt;code&gt;session: { strategy: &quot;jwt&quot; | &quot;database&quot; }&lt;/code&gt; directly, and Clerk manages this decision for you behind its own infrastructure.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Not sure yet?&lt;/strong&gt; Default to database sessions. It's the safer, more debuggable choice, and the performance cost is rarely the bottleneck people assume it'll be — cache the lookup with &lt;code&gt;cache()&lt;/code&gt; as shown above and it disappears from your profiler.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;frequently-asked-questions-1&quot;&gt;Frequently Asked Questions&lt;/h3&gt;
&lt;h4 id=&quot;can-i-combine-both-approaches-&quot;&gt;Can I combine both approaches?&lt;/h4&gt;
&lt;p&gt;Yes — this is actually what &quot;refresh tokens&quot; are: a short-lived JWT for fast verification, backed by a database-stored refresh token for revocation. You get JWT-speed reads most of the time, with a real revocation point when you need one.&lt;/p&gt;
&lt;h4 id=&quot;is-storing-a-jwt-in-a-cookie-the-same-as-storing-it-in-localstorage-&quot;&gt;Is storing a JWT in a cookie the same as storing it in localStorage?&lt;/h4&gt;
&lt;p&gt;No, and the difference matters for security. An &lt;code&gt;httpOnly&lt;/code&gt; cookie can't be read by client-side JavaScript, so it's protected from XSS attacks that steal tokens. &lt;code&gt;localStorage&lt;/code&gt; is fully readable by any script running on your page — a single XSS vulnerability anywhere in your dependency tree can leak every token stored there. Always use &lt;code&gt;httpOnly&lt;/code&gt; cookies for auth tokens in a Next.js app.&lt;/p&gt;
&lt;h4 id=&quot;does-proxy-ts-need-to-fully-verify-the-session-on-every-request-&quot;&gt;Does &lt;code&gt;proxy.ts&lt;/code&gt; need to fully verify the session on every request?&lt;/h4&gt;
&lt;p&gt;No, and for JWT-based auth verifying in &lt;code&gt;proxy.ts&lt;/code&gt; with &lt;code&gt;jose&lt;/code&gt; is genuinely fine performance-wise since it needs no database call. The stronger rule is: never treat &lt;code&gt;proxy.ts&lt;/code&gt; as your &lt;em&gt;only&lt;/em&gt; check. Always re-verify inside the Server Action or Route Handler that actually reads or writes data — &lt;code&gt;proxy.ts&lt;/code&gt; can be bypassed by hitting that endpoint directly.&lt;/p&gt;
&lt;h4 id=&quot;why-hash-the-session-token-before-storing-it-in-the-database-&quot;&gt;Why hash the session token before storing it in the database?&lt;/h4&gt;
&lt;p&gt;The same reason you hash passwords — if your database is ever compromised (a leaked backup, a misconfigured access policy), the attacker gets a list of hashes they can't turn back into working session cookies. It costs you one &lt;code&gt;sha256&lt;/code&gt; call per request, which is effectively free.&lt;/p&gt;
&lt;h4 id=&quot;which-one-does-auth-js-use-by-default-&quot;&gt;Which one does Auth.js use by default?&lt;/h4&gt;
&lt;p&gt;Auth.js defaults to the &lt;code&gt;&quot;jwt&quot;&lt;/code&gt; strategy unless you configure a database adapter, in which case it switches to &lt;code&gt;&quot;database&quot;&lt;/code&gt; automatically. You can also set &lt;code&gt;session: { strategy: &quot;jwt&quot; }&lt;/code&gt; explicitly even with an adapter configured, if you want JWTs for speed while still persisting user accounts.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up-1&quot;&gt;Wrapping Up&lt;/h3&gt;
&lt;p&gt;Sessions and JWTs aren't competing standards — they're two different answers to &quot;where does the source of truth for a logged-in user live?&quot; Sessions keep it on your server, trading a database lookup for instant, total control over who stays logged in. JWTs keep it in the token itself, trading that control for verification that never has to leave the request.&lt;/p&gt;
&lt;p&gt;For most Next.js apps, start with database sessions — the caching pattern above keeps the performance cost near zero, and you'll be glad revocation is a solved problem the first time you actually need it. Reach for JWTs specifically when you've measured that the database hit matters, not because a tutorial defaulted to it.&lt;/p&gt;
&lt;p&gt;From here, a natural next step is wiring either pattern into a real login form with &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/tools/zod-schema-generator&quot;&gt;Zod&lt;/a&gt; validating the credentials before they ever reach your session logic, or layering &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/role-based-authentication-nextjs-16&quot;&gt;role-based permissions&lt;/a&gt; on top of whichever session strategy you pick.&lt;/p&gt;
&lt;h3 id=&quot;continue-learning-1&quot;&gt;Continue Learning&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/how-to-add-nextauth-in-nextjs-16&quot;&gt;How to Add Auth.js (NextAuth v5) Authentication in Next.js 16 (2026)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/how-to-add-clerk-authentication-in-nextjs-16&quot;&gt;How to Add Clerk Authentication in Next.js 16 (2026 Guide)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/clerk-vs-nextauth-2026&quot;&gt;Clerk vs NextAuth (Auth.js) in 2026: Which One Should You Choose?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/role-based-authentication-nextjs-16&quot;&gt;Role-Based Access Control (RBAC) in Next.js 16 (2026 Beginner's Guide)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/supabase-auth-vs-firebase-auth&quot;&gt;Supabase Auth vs Firebase Auth: Which One Should You Choose in 2026?&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;useful-resources&quot;&gt;Useful Resources&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/panva/jose&quot;&gt;jose Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://nextjs.org/docs/app/guides/authentication&quot;&gt;Next.js Authentication Guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html&quot;&gt;OWASP Session Management Cheat Sheet&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/25323/session-vs-jwt-which-should-you-use-in-next-js-16</guid>
<pubDate>Tue, 25 Aug 2026 04:12:26 +0000</pubDate>
</item>
</channel>
</rss>