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

  1. Register and log in with email and password.
  2. See your own notes list — and nobody else's.
  3. Create a note.
  4. Delete a note you own.
  5. 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 own

The 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)
Credentialsid cookieAuthorization: Bearer
Storagebrowser cookie jara JS variable
Sent bythe browseryour code
CORScredentials: true requiredAuthorization header allowed
CSRFneeds protectionnot applicable
Reloadstill logged inneeds 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

Example · bash
# 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.pem

When 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.

Example · javascript
// 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.

Example · html
<!-- 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

  • Be the first to comment on this lesson.