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.
The principle
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
| Point | Rule |
|---|---|
| Generation | 24 random bytes, base64url. Never composed by hand. |
| Scope | One key per site. Never an agency-wide key: a leak stays confined to one client. |
| Storage | Only the argon2id hash exists on the server, in an environment variable. The key is written nowhere. |
| Parameters | m=19456, t=2, p=1 — OWASP recommendation, carried by the hash itself. |
| Comparison | Server-side only, in constant time. |
| Cost | ~350 ms per verification, once per 8-hour session. That is what makes brute force impractical. |
$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.
- Never serve
EDITOR_KEY_HASHto 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
| Cookie | Content | Attributes |
|---|---|---|
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
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.
| Route | Budget | What it protects |
|---|---|---|
/api/auth | 5 per quarter of an hour | the site key |
/api/content | 60 per 5 minutes | the repository API quota |
/api/save | 30 per 5 minutes | the repository, the quota |
/api/upload | 30 per quarter of an hour | the 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.
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:
-
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.
-
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.
-
Identity
verifyAuth, and nothing else. No route evaluates identity by itself. -
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
| Check | Rule |
|---|---|
| Path | src/content/pages/{declared language}/{page}.json. A .., a backslash, a null byte or an encoded sequence fails on its own. |
| Content size | 100 KB |
| Envelope size | 128 KB |
| Schema | The same Zod object as the build. |
| Media | An image must point at a whitelisted file; a video, at a consistent provider/id pair. |
| Item ids | Unique within their list. |
| Outright attack | script, iframe, on…=, javascript: — refused, not merely cleaned. |
| Sanitising | Then applied to every field, whatever its origin. |
| Optimistic lock | The version read on open is compared before writing. |
| Commit message | One line, 120 characters, no control character and no writing-direction mark. |
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
| Variable | Nature | Must never… |
|---|---|---|
GIT_TOKEN | Write token of the machine account | …appear in a response, a served file, a log, localStorage. |
EDITOR_KEY_HASH | Key hash | …be served to the browser. |
SESSION_SECRET | Cookie 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.
-
Generate
in the site project bash npm run make:key -
Replace
EDITOR_KEY_HASHon the host -
Replace
SESSION_SECRET— if neededDo it on a departure or a doubt: it instantly closes every open session. Otherwise, current sessions stay valid until their 8-hour expiry.
-
Redeploy
Variables are only re-read on deployment.
-
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
# 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 # → 405Then, by hand:
- Five wrong keys in a row on
/adminend with a wait message. If the sixth attempt is still refused with “incorrect key”, rate limiting is not active — do not ship. - The
RATE_LIMITbinding 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 key | Possible? |
|---|---|
| Open the overlay by forging the marker | yes — and to no effect: every write is refused. |
| Read the site content | yes — it is public. |
Read the content file via /api/content | no — session required. |
| Publish a change | no — session required. |
Write outside src/content/pages/… | no — even with a valid session. |
| Guess the key by trial | no — 5 attempts per quarter of an hour, 24 random bytes. |
| Retrieve the Git token | no — 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.