Running and Testing the Cookie App

Start it, sign in, then deliberately break it four ways so the failure modes are familiar rather than mysterious.

Running it is the easy part. The valuable part is breaking it on purpose, because these are the exact four failures you will hit in production.

Get it running

  1. Add abc.local and dfg.local to /etc/hosts.
  2. Generate trusted certificates with mkcert.
  3. Start the API on https://dfg.local:8443.
  4. Serve the frontend on https://abc.local:5173.
  5. Open the frontend, create an account, add a note.

Break it #1 — remove credentials: 'include'

Login returns 200. Every call after it returns 401. There is no CORS error, because CORS is fine — the browser simply never stored or sent the cookie.

Break it #2 — change SameSite to Lax

Identical symptom, different cause: the browser refuses to send a Lax cookie on a cross-site fetch. Only the request headers in devtools distinguish this from #1.

Break it #3 — set Access-Control-Allow-Origin to *

Now the browser does complain, loudly, because a wildcard is illegal with credentials.

Break it #4 — open it in Safari

Everything is configured correctly and it still fails, because Safari blocks the third-party cookie. Nothing on the server can fix this. This is the single most important thing to see with your own eyes before choosing cross-site cookies for a real product.

Then verify the security properties

  • document.cookie in the console does not show sidHttpOnly works.
  • A cross-site POST from another page is rejected — CSRF protection works.
  • Deleting another user's note returns 404 — ownership works.
  • After logout, replaying the captured session id fails — server-side revocation works.

Example

Example · bash
# Terminal 1 — the API
cd api && node server.js
# API on https://dfg.local:8443

# Terminal 2 — the frontend
cd web && npx serve -l 5173 \
  --ssl-cert ../abc.local.pem --ssl-key ../abc.local-key.pem

# Browser
open https://abc.local:5173

# Then: create an account, add a note, refresh the page.
# Still signed in? The cookie is doing its job.

When to use it

  • A developer reproduces all four failure modes locally and can afterwards diagnose the same symptoms in production in minutes.
  • A team tests in Safari before committing to cross-site cookies and switches to tokens after seeing the session silently fail.
  • A QA script verifies that a logged-out session id can no longer be replayed, catching a regression where logout only cleared the cookie.

More examples

Verifying the security properties by hand

The last check is the one that catches a broken logout: if it returns 200, the implementation cleared the browser cookie without destroying the session.

Example · bash
# --- HttpOnly: JavaScript must not see the session ---
# In the browser console on https://abc.local:5173
document.cookie
# "XSRF-TOKEN=k3nB..."        ← the CSRF token is readable, by design
#                              ← 'sid' is absent. HttpOnly is working.

# --- Ownership: another user's note must not be deletable ---
curl -b alice-jar.txt -X DELETE \
  https://dfg.local:8443/api/notes/$BOBS_NOTE_ID \
  -H 'Origin: https://abc.local:5173' \
  -H "X-XSRF-TOKEN: $(grep XSRF alice-jar.txt | awk '{print $7}')" \
  --cacert ../rootCA.pem
# → 404 not_found        (not 403 — we do not confirm the note exists)

# --- CSRF: a request with a wrong origin must be refused ---
curl -i -b alice-jar.txt -X POST https://dfg.local:8443/api/notes \
  -H 'Origin: https://evil.local' -H 'Content-Type: application/json' \
  -d '{"text":"pwned"}' --cacert ../rootCA.pem
# → 403 bad_origin

# --- Revocation: a session id must die at logout, server-side ---
SID=$(grep sid alice-jar.txt | awk '{print $7}')
curl -b alice-jar.txt -X POST https://dfg.local:8443/auth/logout \
  -H 'Origin: https://abc.local:5173' \
  -H "X-XSRF-TOKEN: $(grep XSRF alice-jar.txt | awk '{print $7}')" \
  --cacert ../rootCA.pem

curl -i https://dfg.local:8443/api/notes -H "Cookie: sid=$SID" --cacert ../rootCA.pem
# → 401       (the cookie was cleared AND the server record was deleted)

A CSRF attack page, to run against your own app

Keep this file in the repository and open it after any change to the CORS or CSRF configuration — it takes ten seconds and catches regressions that unit tests miss.

Example · html
<!-- Save as evil.html, serve it from a DIFFERENT origin, and open it while
     signed in to the notes app. Everything here should fail. -->
<!doctype html>
<h1>Totally innocent page</h1>

<!-- Attack 1: classic form POST. No custom header possible from a form. -->
<form id="f" method="POST" action="https://dfg.local:8443/api/notes"
      enctype="text/plain">
  <input name='{"text":"pwned","x":"' value='"}'>
</form>
<script>document.getElementById('f').submit();</script>

<!-- Attack 2: fetch with credentials. Blocked before it is even sent, by the
     CORS preflight, because this origin is not on the allowlist. -->
<script>
fetch('https://dfg.local:8443/api/notes', {
  method: 'POST',
  credentials: 'include',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ text: 'pwned' }),
}).then(r => console.log('status', r.status))
  .catch(e => console.log('blocked:', e.message));
</script>

<!-- Attack 3: read the CSRF token to forge a valid request.
     Impossible — the same-origin policy stops evil.local reading dfg.local's
     cookies, and the response of any probe request is unreadable. -->

<!-- Expected results:
       1 → 403 bad_origin (and the form encoding cannot produce valid JSON anyway)
       2 → blocked by CORS; if it got through, 403 csrf_token_mismatch
       3 → not possible
     If ANY of these succeeds, your CSRF protection has a hole. -->

Discussion

  • Be the first to comment on this lesson.