EN FR

Chapter 13Edit

Security and authentication

What is protected is writing, not the interface. The key, the session, the rate budgets, the order of checks, and the rotation procedure.

7 min read13 sectionsChapter 13 / 22

The principle

What is protected is writing

Someone who opens the overlay without the key edits the DOM of their own browser — no more consequential than using developer tools. Do not spend effort locking the interface: all the effort goes on the /api/* routes.

The authentication surface is therefore one route, and the other three simply check a session cookie.

The site key

PointRule
Generation24 random bytes, base64url. Never composed by hand.
ScopeOne key per site. Never an agency-wide key: a leak stays confined to one client.
StorageOnly the argon2id hash exists on the server, in an environment variable. The key is written nowhere.
Parametersm=19456, t=2, p=1 — OWASP recommendation, carried by the hash itself.
ComparisonServer-side only, in constant time.
Cost~350 ms per verification, once per 8-hour session. That is what makes brute force impractical.
hash format text
$argon2id$v=19$m=19456,t=2,p=1$<base64url salt>$<base64url digest>

Since the parameters live in the hash, hardening them later only requires regenerating the key — without touching the code.

Three absolute prohibitions
  • Never serve EDITOR_KEY_HASH to the browser: an exposed hash can be attacked offline.
  • Never compare the key in client-side JavaScript. That is the tempting shortcut while building the overlay, and a total hole.
  • Never log the key, the hash, the cookie or the Git token.

The session

CookieContentAttributes
inline_session Token signed with HMAC-SHA256 using SESSION_SECRET. Nothing is stored server-side. HttpOnly, Secure, SameSite=Strict, 8 h
inline_edit No secret. A plain marker telling the page to load the overlay. readable, forgeable — inconsequential

Forging inline_edit only shows an interface whose writes will all be refused: routes check inline_session, never the marker. Replacing SESSION_SECRET instantly closes every open session.

Rate limiting

The project's most critical security point

Without it, the key falls to brute force. It is also the easiest control to postpone “for later”, because the code works perfectly without it. It belongs to the first batch, not to final hardening.

RouteBudgetWhat it protects
/api/auth5 per quarter of an hourthe site key
/api/content60 per 5 minutesthe repository API quota
/api/save30 per 5 minutesthe repository, the quota
/api/upload30 per quarter of an hourthe repository, the quota

Only the first protects a secret. The others protect the repository against a stolen session or a runaway script: they are wide, and a human editing their page never gets close.

The RATE_LIMIT binding

Counting needs state shared between function instances. Declare a key-value store named RATE_LIMIT on the host.

Without the binding

Counting falls back to an in-memory counter: fine locally, insufficient in production, where each instance would count on its own and a cold start would reset everything.

Host key-value stores are eventually consistent: two near-simultaneous attempts may read the same counter. Protection remains effective against brute force — which assumes thousands of attempts — but it is not exact to the unit. For strict counting, implement RateLimitStore on strongly consistent storage.

The order of checks

Each route refuses in this order, and the order matters as much as the rules:

  1. Rate

    Refusing an insistent caller does not require knowing who they are — and a stolen session must not be able to hammer the repository API.

  2. Declared size

    No point holding 200 MB in memory only to discover it exceeds the cap. The header can lie: this is not the protection, it is the saving.

  3. Identity

    verifyAuth, and nothing else. No route evaluates identity by itself.

  4. Received size, shape, path, schema, lock, write

    The real cap is measured on received bytes, not on what was announced.

A size cap placed after reading the body protects nothing. That is why the tests call the routes directly rather than testing rules in isolation: it is the only way to verify the order.

What the write function verifies

CheckRule
Pathsrc/content/pages/{declared language}/{page}.json. A .., a backslash, a null byte or an encoded sequence fails on its own.
Content size100 KB
Envelope size128 KB
SchemaThe same Zod object as the build.
MediaAn image must point at a whitelisted file; a video, at a consistent provider/id pair.
Item idsUnique within their list.
Outright attackscript, iframe, on…=, javascript:refused, not merely cleaned.
SanitisingThen applied to every field, whatever its origin.
Optimistic lockThe version read on open is compared before writing.
Commit messageOne line, 120 characters, no control character and no writing-direction mark.
Why refuse rather than clean

A paste from a word processor never carries a <script>: the overlay already removed it. Seeing one arrive means the route is being called directly. Cleaning would be technically sufficient, but refusing leaves a trace in the logs.

Why server-side sanitising does not use DOMPurify

DOMPurify needs a DOM, which the function runtime does not provide. With a pure-JavaScript DOM, DOMPurify raises no error: it sets isSupported to false and returns its input unchanged. Verified in the runtime, a <script> and an href="javascript:" came back intact.

So the server sanitiser rebuilds the fragment from its parse: only the whitelist is written back, the rest does not exist in the result. A test submits the same corpus to both implementations — browser and server — and compares the outputs: that is what guarantees they do not diverge.

Secrets

VariableNatureMust never…
GIT_TOKENWrite token of the machine account…appear in a response, a served file, a log, localStorage.
EDITOR_KEY_HASHKey hash…be served to the browser.
SESSION_SECRETCookie signature…be shared between sites.

On GitHub, the token must be:

  • that of a dedicated machine account, not a person;
  • fine-grained, limited to the site's repository only;
  • with the Contents: Read and write permission, and nothing else.

Two automated checks stand guard: check-logs.mjs refuses a console.* that would evaluate a token, a hash, a cookie or an object containing them; check-secrets.mjs verifies no secret ends up in the build folder. See Checks and tests.

Key rotation

To be done when someone leaves, in case of doubt, or periodically.

  1. Generate

    in the site project bash
    npm run make:key
  2. Replace EDITOR_KEY_HASH on the host

  3. Replace SESSION_SECRET — if needed

    Do it on a departure or a doubt: it instantly closes every open session. Otherwise, current sessions stay valid until their 8-hour expiry.

  4. Redeploy

    Variables are only re-read on deployment.

  5. Send the new key

    Through a channel separate from the one carrying the editing address.

The old key stops working at step 4. There is no overlap period: that is deliberate — two valid keys at once is the kind of convenience one forgets to leave.

Verifying an installation

after deployment bash
# Content is indeed in the raw HTML
curl -s https://the-site.com/ | grep -c "a page title"          # → 1

# Writing is closed without a session
curl -s -o /dev/null -w "%{http_code}" -X POST https://the-site.com/api/save   # → 401

# No unexpected method is open
curl -s -o /dev/null -w "%{http_code}" https://the-site.com/api/save           # → 405

Then, by hand:

  • Five wrong keys in a row on /admin end with a wait message. If the sixth attempt is still refused with “incorrect key”, rate limiting is not active — do not ship.
  • The RATE_LIMIT binding is declared.
  • Variables are set as runtime secrets, not build variables.
  • The token is restricted to that single repository.
  • The key has been delivered, and erased everywhere else.

What an attacker can, and cannot, do

Without the keyPossible?
Open the overlay by forging the markeryes — and to no effect: every write is refused.
Read the site contentyes — it is public.
Read the content file via /api/contentno — session required.
Publish a changeno — session required.
Write outside src/content/pages/…no — even with a valid session.
Guess the key by trialno — 5 attempts per quarter of an hour, 24 random bytes.
Retrieve the Git tokenno — it never leaves the function.

Moving to several users

That need is met by replacing a single implementation: verifyAuth / createSession, with delegated authentication (Cloudflare Access, Supabase Auth, an identity provider). It is outside the v1 scope — but nothing in the rest of the code should prevent the switch, and that is exactly why no identity check exists anywhere else.