Tutorials Ultimate Web Development Series › Chapter 11

Authentication — Sessions, Cookies, and JWT From Scratch

WebChapter 11 of the Ultimate Web Development Series35 minApril 20, 2026Intermediate

Your notes API is now durable and accepts file uploads. But right now anyone on the internet can create, read, or delete anyone else's notes. That's not an app — it's a public bulletin board with a database attached. This chapter adds the one thing separating a toy from a product: the server knowing who is making each request.

By the end you'll have signup and login endpoints that hash passwords correctly, issue signed tokens, and an authed() helper that every protected endpoint uses to resolve "who is this?" — all in hand-written code, no frameworks, running on a Cloudflare Worker.

The Three Words You Need to Keep Separate

People say "auth" and mean three different things. Don't conflate them.

| Term | Answers | Example | |---|---|---| | Identity | "Who is this account?" | user_id = 42 | | Authentication (authN) | "Prove you're that account" | Correct password or valid token | | Authorization (authZ) | "Are you allowed to do this?" | Is the note's user_id yours? |

Authentication checks credentials to confirm identity. Authorization checks permissions once identity is known. A user can be authenticated but unauthorized (logged in but trying to delete someone else's note). A perfect login with no authorization check is a massive security hole. We'll cover both.

Passwords — The One Rule

Never store passwords in plaintext. If your DB is leaked, every user's password (and probably their password on 12 other sites) is compromised. Instead, store a hash — a one-way function of the password that lets you verify a login without knowing the password.

Good hashing functions for passwords are deliberately slow (so attackers can't brute-force them). The modern choices:

Workers don't ship Argon2 in the runtime, but the Web Crypto API has PBKDF2 — older but acceptable if you use high iteration counts (>= 100,000). For serious apps I'd integrate Argon2 via argon2-browser or delegate to a third-party auth provider. For this chapter, PBKDF2 keeps things self-contained.

// password-hash.js — Worker-compatible password hashing

const ITERATIONS = 100_000;

async function hashPassword(password) {
  const salt = crypto.getRandomValues(new Uint8Array(16));
  const derived = await deriveKey(password, salt);
  // Store as "iter:salt_base64:derived_base64" so you know how to verify later
  return `${ITERATIONS}:${b64(salt)}:${b64(new Uint8Array(derived))}`;
}

async function verifyPassword(password, stored) {
  const [iterStr, saltB64, derivedB64] = stored.split(":");
  const iter = Number(iterStr);
  const salt = fromB64(saltB64);
  const expected = fromB64(derivedB64);
  const derived = new Uint8Array(await deriveKey(password, salt, iter));
  // Constant-time compare
  if (derived.length !== expected.length) return false;
  let ok = 0;
  for (let i = 0; i < derived.length; i++) ok |= derived[i] ^ expected[i];
  return ok === 0;
}

async function deriveKey(password, salt, iter = ITERATIONS) {
  const key = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(password),
    { name: "PBKDF2" },
    false,
    ["deriveBits"]
  );
  return crypto.subtle.deriveBits(
    { name: "PBKDF2", salt, iterations: iter, hash: "SHA-256" },
    key,
    256
  );
}

function b64(bytes) {
  return btoa(String.fromCharCode(...bytes));
}
function fromB64(s) {
  return new Uint8Array(atob(s).split("").map(c => c.charCodeAt(0)));
}

Two things worth highlighting:

Sessions vs Tokens — Two Ways to Remember a User

HTTP is stateless (Ch 7). Every request stands alone. So the client has to prove who it is on every request. Two approaches:

Sessions (server-side state)

After login, the server generates a random session ID, stores session_id → user_id in a table, and sends the ID back in a cookie. The client sends that cookie with every request; the server looks up the ID and finds the user.

Tokens (stateless, usually JWT)

After login, the server generates a signed token containing the user ID, an expiry, and anything else it wants to prove. The client sends the token with every request; the server verifies the signature cryptographically — no DB lookup needed.

For edge workloads like Cloudflare Workers, JWTs win. We'll build one by hand.

Cookies vs Authorization Headers — Two Ways to Transport Tokens

Once you have a token, how does the client send it on each request?

Cookies — Set-Cookie + auto-sent

Set-Cookie: sas_session=eyJ...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=2592000

Four flags to always set:

The browser sends cookies automatically on every request to your domain. The client doesn't have to do anything.

Authorization header — Bearer <token>

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ...

Client code explicitly attaches the header on every request:

fetch("/api/notes", {
  headers: { Authorization: `Bearer ${token}` },
});

This is the standard for mobile apps, CLIs, server-to-server calls, and any front-end that doesn't share a domain with the API.

When to use which

| Situation | Pick | |---|---| | Browser frontend on the same domain | Cookie (auto-sent, harder to steal) | | Mobile app, CLI, or API clients | Bearer token (you control the transport) | | Frontend on a different domain | Either, with care — cookies need SameSite=None + CORS |

Many apps end up supporting both — a cookie for browser usage, a bearer header for API consumers. Your Worker doesn't care; it just needs to read the token from wherever it is.

JWT — Anatomy

A JSON Web Token is three base64url-encoded chunks separated by dots:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsImV4cCI6MTc2NDU5MjAwMH0.V_JkL1z...
└───── header ─────┘ └───────── payload ─────────┘ └── signature ──┘
Loading diagram…

Figure 1 — A JWT is three pieces: a header saying how it's signed, a payload holding user data (called "claims"), and a signature that proves the payload wasn't tampered with.

Signing JWTs in a Worker — The Actual Code

Workers don't have a jsonwebtoken npm package by default, and you don't need one. Web Crypto gives you HMAC in ~40 lines:

// jwt.js — Worker-compatible HS256 JWT helpers

function b64urlEncode(bytes) {
  const bin = String.fromCharCode(...new Uint8Array(bytes));
  return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function b64urlEncodeString(str) {
  return b64urlEncode(new TextEncoder().encode(str));
}
function b64urlDecodeToString(s) {
  s = s.replace(/-/g, '+').replace(/_/g, '/');
  while (s.length % 4) s += '=';
  return atob(s);
}

async function hmacSha256(secret, data) {
  const key = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"]
  );
  return crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
}

export async function signJWT(payload, secret, expiresInSeconds = 60 * 60 * 24 * 30) {
  const now = Math.floor(Date.now() / 1000);
  const full = { ...payload, iat: now, exp: now + expiresInSeconds };
  const header = b64urlEncodeString(JSON.stringify({ alg: "HS256", typ: "JWT" }));
  const body = b64urlEncodeString(JSON.stringify(full));
  const sig = b64urlEncode(await hmacSha256(secret, `${header}.${body}`));
  return `${header}.${body}.${sig}`;
}

export async function verifyJWT(token, secret) {
  try {
    const [h, p, sig] = (token ?? "").split(".");
    if (!h || !p || !sig) return null;
    const expected = b64urlEncode(await hmacSha256(secret, `${h}.${p}`));
    if (expected !== sig) return null;                         // signature invalid
    const payload = JSON.parse(b64urlDecodeToString(p));
    if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) return null;  // expired
    return payload;
  } catch {
    return null;
  }
}

Two functions. signJWT(payload, secret) issues a token. verifyJWT(token, secret) returns the payload on success or null on any failure (bad format, bad signature, expired).

Store the secret as a Worker secret, never in code:

wrangler secret put JWT_SECRET
# paste a long random string — at least 32 characters

Access as env.JWT_SECRET in your Worker.

Login Flow — Putting It Together

Loading diagram…

Figure 2 — Signup, login, then authenticated read. The cookie is set once and auto-accompanies every subsequent request.

The Full Auth Endpoints

Here's the signup/login/logout trio. Paste this into your Worker next to the notes routes:

import { signJWT, verifyJWT } from "./jwt.js";
import { hashPassword, verifyPassword } from "./password-hash.js";

// POST /api/signup
if (pathname === "/api/signup" && method === "POST") {
  const { email, password } = await request.json();
  if (!email || !password || password.length < 8) {
    return cors(json({ error: "validation_failed", message: "email + 8+ char password required" }, 400));
  }
  const existing = await env.DB
    .prepare("SELECT id FROM users WHERE email = ?")
    .bind(email.toLowerCase())
    .first();
  if (existing) return cors(json({ error: "conflict", message: "email already registered" }, 409));

  const userId = "u_" + crypto.randomUUID().slice(0, 8);
  const passwordHash = await hashPassword(password);
  await env.DB
    .prepare("INSERT INTO users (id, email, password_hash) VALUES (?, ?, ?)")
    .bind(userId, email.toLowerCase(), passwordHash)
    .run();

  const token = await signJWT({ sub: userId }, env.JWT_SECRET);
  return new Response(JSON.stringify({ id: userId, email }), {
    status: 201,
    headers: {
      "Content-Type": "application/json",
      "Set-Cookie": buildSessionCookie(token),
    },
  });
}

// POST /api/login
if (pathname === "/api/login" && method === "POST") {
  const { email, password } = await request.json();
  const user = await env.DB
    .prepare("SELECT id, email, password_hash FROM users WHERE email = ?")
    .bind((email ?? "").toLowerCase())
    .first();
  // Same response for "no user" and "wrong password" — don't leak which
  if (!user || !(await verifyPassword(password, user.password_hash))) {
    return cors(json({ error: "unauthorized", message: "bad credentials" }, 401));
  }
  const token = await signJWT({ sub: user.id }, env.JWT_SECRET);
  return new Response(JSON.stringify({ id: user.id, email: user.email }), {
    status: 200,
    headers: {
      "Content-Type": "application/json",
      "Set-Cookie": buildSessionCookie(token),
    },
  });
}

// POST /api/logout
if (pathname === "/api/logout" && method === "POST") {
  return new Response(null, {
    status: 204,
    headers: { "Set-Cookie": "sas_session=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0" },
  });
}

function buildSessionCookie(token) {
  const days = 30;
  return `sas_session=${token}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${days * 86400}`;
}

Five rules you can read off the code:

  1. Email normalized to lowercase on both write and read — no accidental duplicate accounts.
  2. "No user" and "wrong password" return the same error. Otherwise attackers learn which emails are registered.
  3. Password min length ≥ 8. Not perfect, but better than nothing.
  4. Set-Cookie built with HttpOnly + Secure + SameSite=Lax every time.
  5. Logout sends an empty cookie with Max-Age=0, telling the browser to discard it.

The authed() Helper — Gating Endpoints

Every protected endpoint needs "figure out who this is, or 401." A tiny helper:

async function authed(request, env) {
  // Read token from cookie OR Authorization header
  const cookieHeader = request.headers.get("Cookie") ?? "";
  const cookieMatch = cookieHeader.match(/(?:^|;\s*)sas_session=([^;]+)/);
  const bearer = request.headers.get("Authorization")?.replace(/^Bearer\s+/i, "");
  const token = cookieMatch?.[1] ?? bearer;

  if (!token) return null;
  const payload = await verifyJWT(token, env.JWT_SECRET);
  if (!payload?.sub) return null;

  return await env.DB
    .prepare("SELECT id, email FROM users WHERE id = ?")
    .bind(payload.sub)
    .first();
}

Usage in routes:

// GET /api/notes — now only the user's own notes
if (pathname === "/api/notes" && method === "GET") {
  const user = await authed(request, env);
  if (!user) return cors(json({ error: "unauthorized" }, 401));

  const { results } = await env.DB
    .prepare("SELECT * FROM notes WHERE user_id = ? ORDER BY created_at DESC LIMIT 100")
    .bind(user.id)
    .all();
  return cors(json(results));
}

// DELETE /api/notes/:id — must be the owner
if (match && method === "DELETE") {
  const user = await authed(request, env);
  if (!user) return cors(json({ error: "unauthorized" }, 401));

  const note = await env.DB.prepare("SELECT user_id FROM notes WHERE id = ?").bind(match[1]).first();
  if (!note) return cors(json({ error: "not_found" }, 404));
  if (note.user_id !== user.id) return cors(json({ error: "forbidden" }, 403));

  await env.DB.prepare("DELETE FROM notes WHERE id = ?").bind(match[1]).run();
  return cors(new Response(null, { status: 204 }));
}

Read the DELETE carefully — it shows both auth checks in action:

Three distinct outcomes, three distinct status codes. Chapter 7's 401-vs-403 callout now matters for real.

CSRF — The One Attack Cookies Don't Block Automatically

With cookie-based auth, an attacker's malicious page can trigger a request to your API from the user's browser, and the browser will happily send the user's cookie. That's CSRF (Cross-Site Request Forgery).

Three defences, in order of most-to-least important:

  1. SameSite=Lax on the cookie (or Strict for API-only sites). Kills 95% of CSRF in 2026 browsers. You already did this.
  2. CORS. Your API Access-Control-Allow-Origin: https://myapp.com prevents browser JS on other origins from reading responses. Also already in place.
  3. CSRF tokens — a per-session random token the browser echoes in a header on state-changing requests. Belt-and-braces if you need to support older browsers or have very high-value endpoints.

For bearer tokens, CSRF isn't a concern — the attacker's page can't read the token from your app's localStorage (cross-origin) so can't forge a request.

Schema Migration — Add Users + user_id to Notes

-- migrations/0003_users_and_auth.sql
CREATE TABLE IF NOT EXISTS users (
  id            TEXT    PRIMARY KEY,
  email         TEXT    UNIQUE NOT NULL,
  password_hash TEXT    NOT NULL,
  created_at    TEXT    DEFAULT (datetime('now'))
);

-- If you didn't already add user_id to notes in Ch 9:
ALTER TABLE notes ADD COLUMN user_id TEXT REFERENCES users(id) ON DELETE CASCADE;
CREATE INDEX IF NOT EXISTS idx_notes_user_id ON notes(user_id);

Apply with the usual wrangler d1 execute ... --local and --remote.

Exercise — Gate Every Endpoint

Take the Worker from Chs 8-10 and make it multi-tenant:

  1. Run the users+password migration.
  2. Add the password-hash module and JWT module.
  3. Implement /api/signup, /api/login, /api/logout.
  4. Add the authed() helper.
  5. Wrap every /api/notes* endpoint with authed(). Include ownership checks on update/delete.
  6. Test: sign up as user A, create a note. Sign up as user B, try to read/delete A's note — should get 403 / 404.

You now have a real multi-user backend. The hardest part of web development is behind you; everything from here is integration work.

Next Steps

Ch 12 replaces "passwords you invent" with "Sign in with Google" — the same flow, different issuer. Ch 13 teaches Stripe subscriptions. Ch 14 is the Part 2 capstone: we dissect the real auth + Stripe code inside saas/src/index.js (the Worker backing simpleappshipper.com) and you'll see every pattern from this chapter in production.

Next:

  1. Rotate your JWT_SECRET if you ever paste it anywhere. wrangler secret put JWT_SECRET with a new value logs everyone out — that's sometimes exactly what you want.
  2. Read the next chapter — Ch 12: Google OAuth 2.0 Step-by-Step, where we let users skip the password dance entirely.
Ch 10: Object Storage with R2Ch 12: Google OAuth 2.0 Step-by-Step

Ship your apps faster

When you're ready to publish your Swift app to the App Store, Simple App Shipper handles metadata, screenshots, TestFlight, and submissions — all in one place.

Try Simple App Shipper
5 free articles remainingSubscribe for unlimited access