DevStacked
AuthenticationAugust 29, 202615 min read

What Is Authentication? A Beginner's Guide (2026)

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 authentication, and it's one of those concepts every developer touches constantly but rarely stops to explain from scratch.

If you've ever wondered what actually happens between "click Sign In" and "welcome back," or mixed up authentication with authorization 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.

What Is Authentication? (Quick Answer)

Authentication is the process of verifying that someone is who they claim to be. It answers one question: "Who are you?" 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.

Authentication is different from authorization, which answers a separate question: "What are you allowed to do?" 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.

💡 Simple way to remember it: Authentication = ID check at the door. Authorization = the guest list that decides which rooms you can enter once you're inside.

Why Authentication Matters

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.

It matters for a few concrete reasons:

  • Privacy — only the right person sees their own data
  • Security — prevents account takeover and unauthorized access
  • Personalization — apps can remember who you are across visits
  • Accountability — actions can be traced back to a real identity (important for admin tools, financial apps, audit logs)

Authentication vs. Authorization: The Difference That Trips Everyone Up

AuthenticationAuthorization
Question it answersWho are you?What can you do?
HappensFirstAfter authentication
ExampleLogging in with a passwordOnly admins can access /admin
Failure result401 Unauthorized403 Forbidden

Why the HTTP status codes are confusingly named: 401 Unauthorized actually means "you're not authenticated" (log in first), while 403 Forbidden means "you're authenticated, but not allowed to do this." Yes, 401 should really be called "Unauthenticated" — it's a long-standing naming quirk in the HTTP spec that trips up almost every developer at least once.

Related: Role-Based Access Control (RBAC) in Next.js 16 goes deep on the authorization half of this once you're ready for it.

How Authentication Works (Step by Step)

At a high level, almost every authentication system follows the same basic loop:

  1. The user provides credentials — a password, a fingerprint, a one-time code, or an existing account from another provider (like signing in with Google)
  2. The server verifies those credentials — checking a hashed password, validating a signed token, or confirming a biometric match
  3. The server issues proof of identity — usually a session or a token, stored in a cookie or sent back to the client
  4. That proof is sent with future requests — so the user doesn't have to log in again on every page
User submits credentials
        
Server verifies credentials
        
Server issues session/token
        
Client stores it (cookie)
        
Future requests include it
        
Server verifies it's still valid

That last step — verifying the proof of identity on every request — is what keeps a user "logged in" without asking for their password on every single page.

The Main Types of Authentication

There isn't just one way to authenticate someone. Here are the methods you'll actually run into as a developer.

1. Password-Based Authentication

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 Argon2id. If it's not available, bcrypt or another appropriate password hashing algorithm can be used.

// lib/auth/hash-password.ts
import bcrypt from "bcryptjs";

const SALT_ROUNDS = 12;

export async function hashPassword(plainPassword: string): Promise<string> {
  return bcrypt.hash(plainPassword, SALT_ROUNDS);
}

export async function verifyPassword(
  plainPassword: string,
  hashedPassword: string
): Promise<boolean> {
  return bcrypt.compare(plainPassword, hashedPassword);
}

What's happening here: bcrypt.hash() 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. SALT_ROUNDS = 12 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.

⚠️ bcrypt limitation: bcrypt has a hard 72-byte input limit, and most implementations (including bcryptjs) 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 characters can still get silently cut short in bytes — which can cause two different passwords to be treated as equivalent after truncation.
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.

⚠️ Common Mistake: Comparing passwords with a plain === check after "encrypting" them with something reversible like Base64. That's encoding, not hashing — it can be reversed instantly. Only use a dedicated password-hashing library.

2. Session-Based Authentication

After a successful login, the server creates a session record stored in server-side session storage such as a database or shared cache — and gives the browser a random session ID in an httpOnly cookie. On every request, the server looks up that ID to know who's logged in.

// lib/auth/session.ts
import { cookies } from "next/headers";
import { randomUUID } from "crypto";

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("session_id", sessionId, {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    expires: expiresAt,
    path: "/",
  });
}

Why each flag matters: httpOnly stops client-side JavaScript from reading the cookie (blocking a common XSS attack path). secure means it's only sent over HTTPS. sameSite: "lax" 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.

Pros: 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.
Cons: can require a server-side session lookup on authenticated requests, although caching can reduce the cost.

3. Token-Based Authentication (JWT)

Instead of looking anything up server-side, a JSON Web Token (JWT) 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.

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

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

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

export async function verifyToken(token: string) {
  try {
    const { payload } = await jwtVerify(token, secretKey, {
      algorithms: ["HS256"],
    });
    return payload;
  } catch {
    return null; // invalid or expired
  }
}

What's happening here: jose 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. setSubject(userId) stores the user's ID in the token's standard sub claim. jwtVerify() automatically rejects the token if the signature doesn't match or if it's expired, so you never write manual expiry checks.

⚠️ Signing is not encryption. 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.

Pros: no database lookup needed to verify identity, works well across distributed/stateless servers.
Cons: harder to instantly revoke a single token before it expires (it's valid until it naturally expires unless you build extra machinery around it).

💡 For a deeper side-by-side of these two models — including how each behaves at scale — see Session vs JWT: Which Should You Use in Next.js 16?

4. OAuth / OpenID Connect (Social Login)

OAuth 2.0 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.

For "Sign in with Google," "Sign in with Microsoft," and similar login flows, applications commonly use OpenID Connect (OIDC) — an identity layer built on top of OAuth 2.0. The identity provider authenticates the user and returns an ID token containing claims about that authentication.

User clicks "Sign in with GitHub"
        
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

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.

5. Multi-Factor Authentication (MFA / 2FA)

MFA requires more than one proof of identity — typically "something you know" (a password) plus "something you have" (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.

6. Passwordless Authentication (Magic Links & Passkeys)

Two increasingly common alternatives to passwords:

  • Magic links — the user enters their email, gets a one-time sign-in link, and clicking it logs them in. No password to remember or leak.
  • Passkeys (WebAuthn) — 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.

A Minimal Authentication Flow in Next.js

Here's a simplified but realistic login flow using Next.js 16, TypeScript, and session cookies — the same core pattern used by most production apps, just without the database wiring.

// actions/login.ts
"use server";

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

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("email"),
    password: formData.get("password"),
  });

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

  // 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: "Invalid email or password." };
  }

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

  redirect("/dashboard");
}

What's happening here: the form data is validated with Zod before touching the database — never trust raw input. Notice the login failure returns the same generic message 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.

⚠️ Common Mistake: 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 proxy.ts.

Building this by hand is a great way to learn, but most production apps reach for a maintained library instead — see How to Add Auth.js (NextAuth v5) in Next.js 16, Add Clerk Authentication to Next.js 16, or (if you're already on Supabase) Supabase Auth vs Firebase Auth.

Common Authentication Mistakes to Avoid

  • Storing plain-text passwords — always hash with Argon2id or bcrypt, never store or log the raw password
  • Storing a predictable value as the session cookie — the cookie should be a random, unguessable reference, never the raw user ID or anything else an attacker could construct
  • Weak or reused session/JWT secrets — generate a long, random secret and never commit it to git
  • Revealing which field was wrong — "Invalid email" vs. "Invalid password" tells attackers an account exists
  • Skipping rate limiting — without it, attackers can brute-force passwords with unlimited attempts
  • Relying only on client-side checks — a hidden button or disabled form field doesn't stop a direct API request
  • Forgetting to expire sessions/tokens — every session and token should have a sane expiry, not live forever

Frequently Asked Questions

What is the difference between authentication and authorization?

Authentication confirms who someone is (logging in). Authorization decides what they're allowed to do once they're recognized (permissions, roles, access control). You always need authentication before authorization can happen.

Is a login system the same thing as authentication?

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.

What's the difference between sessions and JWTs?

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.

Keep JWT minimal — a JWT should not be treated as a general-purpose user profile or database replacement.

Do I need a library to build authentication, or can I build it myself?

You can build basic password + session authentication yourself using the patterns above, but for anything production-facing, an established library (Auth.js, Clerk, Better Auth, 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.

Is two-factor authentication (2FA) actually necessary?

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.

Are passkeys better than passwords?

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 option alongside passwords rather than a full replacement.

Wrapping Up

Authentication boils down to one question — "who are you?" — 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.

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.

Continue Learning

Free Developer Tools

  • Zod Schema Generator — generate a validated schema for your role/permission payloads straight from a JSON sample or TypeScript interface, including a ready-to-use Server Action.
  • Supabase RLS Policy Generator — pair this guide's application-level RBAC with database-level Row Level Security. Generates owner-only, team, and admin-override policies with the auth.uid() performance wrapper built in.
AuthenticationWeb SecuritySessionsJWTOAuthNext.js
Share On