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:
- bcrypt — 25 years old, still fine.
- scrypt — stronger, more memory-hard.
- Argon2id — winner of the 2015 password-hashing competition. Best available today.
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:
crypto.getRandomValues()for the salt — each user has a unique salt so attackers can't reuse a single rainbow table.- Constant-time comparison in
verifyPassword— comparing bytes with===can leak timing info about where the first mismatch was. XOR-loop + equals-zero avoids that.
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.
- Upside: you can revoke sessions instantly (delete the row).
- Downside: every request costs a DB lookup.
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.
- Upside: no per-request DB round-trip; scales infinitely.
- Downside: revoking before expiry needs extra plumbing (a blocklist).
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:
HttpOnly— JavaScript can't read the cookie. Prevents XSS-based theft.Secure— only sent over HTTPS. Non-negotiable in production.SameSite=Lax(orStrict) — not sent on cross-site requests. Kills most CSRF.Max-Age— when to expire in seconds.
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 ──┘
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.
- Header — algorithm + type. We use
HS256(HMAC-SHA256, symmetric — same secret signs and verifies). - Payload — claims. Standard ones:
sub(subject = user ID),iat(issued at),exp(expiry timestamp). Custom ones:tier: "pro",isAdmin: true. - Signature — cryptographic proof the payload is from you. Anyone can read a JWT (it's base64, not encrypted); only whoever has the secret can sign one.
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
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:
- Email normalized to lowercase on both write and read — no accidental duplicate accounts.
- "No user" and "wrong password" return the same error. Otherwise attackers learn which emails are registered.
- Password min length ≥ 8. Not perfect, but better than nothing.
Set-Cookiebuilt withHttpOnly + Secure + SameSite=Laxevery time.- 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:
- 401 Unauthorized — not logged in
- 403 Forbidden — logged in, but this note belongs to someone else
- 404 Not Found — the note doesn't exist
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:
SameSite=Laxon the cookie (orStrictfor API-only sites). Kills 95% of CSRF in 2026 browsers. You already did this.- CORS. Your API
Access-Control-Allow-Origin: https://myapp.comprevents browser JS on other origins from reading responses. Also already in place. - 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:
- Run the users+password migration.
- Add the password-hash module and JWT module.
- Implement
/api/signup,/api/login,/api/logout. - Add the
authed()helper. - Wrap every
/api/notes*endpoint withauthed(). Include ownership checks on update/delete. - 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:
- Rotate your
JWT_SECRETif you ever paste it anywhere.wrangler secret put JWT_SECRETwith a new value logs everyone out — that's sometimes exactly what you want. - Read the next chapter — Ch 12: Google OAuth 2.0 Step-by-Step, where we let users skip the password dance entirely.
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