The Demo App: abc.com and dfg.com
One small application, built twice — once with cookies and once with bearer tokens — so you can compare the same feature line by line.
The next two categories build the same application twice. Same features, same endpoints, same data; only the authentication differs. Reading them side by side is the fastest way to internalise the trade-offs.
The scenario
https://abc.com— the frontend. Static HTML and JavaScript, no framework needed.https://dfg.com— the API. Node + Express.- Different origins and different sites, which is what makes it interesting.
The features
- Register and log in with email and password.
- See your own notes list — and nobody else's.
- Create a note.
- Delete a note you own.
- Log out.
The endpoints
POST /auth/register create an account
POST /auth/login start a session / get a token
POST /auth/logout end it
GET /api/me who am I?
GET /api/notes my notes
POST /api/notes create one
DELETE /api/notes/:id delete one I ownThe shared foundation
Both versions use the same user store, the same Argon2 password hashing, the same rate limiting, and the same ownership checks. That is deliberate: the authentication scheme is the only variable. Everything else is identical because everything else should be identical — swapping cookies for tokens does not change how you hash a password or scope a query.
What differs
| Version 1 (cookies) | Version 2 (tokens) | |
|---|---|---|
| Credential | sid cookie | Authorization: Bearer |
| Storage | browser cookie jar | a JS variable |
| Sent by | the browser | your code |
| CORS | credentials: true required | Authorization header allowed |
| CSRF | needs protection | not applicable |
| Reload | still logged in | needs a refresh call |
Running it locally
You need two real HTTPS origins — SameSite=None; Secure cookies do not work over plain HTTP. mkcert plus two hostnames in /etc/hosts takes about two minutes and makes the whole thing reproducible.
Example
# Two real hostnames, locally
sudo sh -c 'echo "127.0.0.1 abc.local dfg.local" >> /etc/hosts'
# Certificates browsers actually trust
mkcert -install
mkcert abc.local dfg.local
# Project layout
demo/
├── api/ # dfg.local:8443
│ ├── server.js
│ ├── db.js
│ └── package.json
└── web/ # abc.local:5173
├── index.html
└── app.js
# Two terminals
cd api && npm install express cookie-parser cors argon2 && node server.js
cd web && npx serve -l 5173 --ssl-cert ../abc.local.pem --ssl-key ../abc.local-key.pemWhen to use it
- A developer builds both versions locally to see for themselves which requests carry a Cookie header and which carry an Authorization header.
- A team uses the cookie version as the baseline and diffs it against the token version to scope a migration.
- A workshop runs both apps in Safari to demonstrate third-party cookie blocking on real code rather than in the abstract.
More examples
The shared user and notes store
deleteNote returning false for someone else's note — rather than throwing a 403 — is the ownership check done properly: the API never confirms that a note it will not show you exists.
// db.js — deliberately in-memory so the auth code is the only thing to read.
// Swap for Postgres by replacing these functions; nothing else changes.
import argon2 from 'argon2';
import { randomUUID } from 'crypto';
const users = new Map(); // email -> user
const notes = new Map(); // id -> note
export const db = {
async createUser(email, password) {
email = email.toLowerCase().trim();
if (users.has(email)) throw new Error('email_taken');
const user = {
id: randomUUID(),
email,
// Argon2id: memory-hard, the current default recommendation
passwordHash: await argon2.hash(password, { type: argon2.argon2id }),
createdAt: new Date(),
};
users.set(email, user);
return user;
},
async verifyPassword(email, password) {
const user = users.get(String(email).toLowerCase().trim());
// Hash even when the user does not exist, so the response time is the same.
const hash = user?.passwordHash ?? DUMMY_HASH;
const ok = await argon2.verify(hash, password).catch(() => false);
return user && ok ? user : null;
},
findUserById: (id) => [...users.values()].find((u) => u.id === id) ?? null,
// Every notes query is scoped by userId — authorization lives HERE, not in a
// check after the fetch.
listNotes: (userId) =>
[...notes.values()].filter((n) => n.userId === userId)
.sort((a, b) => b.createdAt - a.createdAt),
createNote(userId, text) {
const note = { id: randomUUID(), userId, text, createdAt: new Date() };
notes.set(note.id, note);
return note;
},
deleteNote(userId, id) {
const note = notes.get(id);
if (!note || note.userId !== userId) return false; // not yours = not found
notes.delete(id);
return true;
},
};
const DUMMY_HASH = await argon2.hash('dummy-password-for-timing-equalisation');
export const publicProfile = (u) => ({ id: u.id, email: u.email });The one HTML page, used by both versions
Both frontends share this page. Only app.js differs, which keeps the comparison down to the twenty or so lines that actually handle credentials.
<!-- web/index.html — no framework, so the auth code has nowhere to hide -->
<!doctype html>
<meta charset="utf-8">
<title>Notes — abc.com</title>
<main id="app">
<section id="auth">
<h1>Sign in</h1>
<form id="login-form">
<input name="email" type="email" autocomplete="username" required>
<input name="password" type="password" autocomplete="current-password" required>
<button type="submit">Sign in</button>
<button type="button" id="register">Create account</button>
</form>
<p id="auth-error" role="alert"></p>
</section>
<section id="notes" hidden>
<h1>Your notes</h1>
<p>Signed in as <strong id="who"></strong>
<button id="logout">Sign out</button></p>
<form id="note-form">
<input name="text" placeholder="Write a note…" required>
<button type="submit">Add</button>
</form>
<ul id="note-list"></ul>
</section>
</main>
<script type="module" src="./app.js"></script>
Discussion