Authentication & Security
The authentication model is small on purpose: one shared editor password, signed session cookies, and a CSRF header on every mutation. This page covers all three plus the production checklist.
The model
Section titled “The model”Setting the editor password
Section titled “Setting the editor password”CARET_EDIT_PASSWORD=long-random-passphraseCARET_SESSION_SECRET=different-long-random-string| Variable | What it does |
|---|---|
CARET_EDIT_PASSWORD | Checked on POST /api/cms/auth/login with constant-time compare |
CARET_SESSION_SECRET | Signs session cookies — always set in production |
EDIT_PASSWORD | Fallback for backward compat (prefer the prefixed version) |
Generate random values:
node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"Run the command twice — once for the password (or pick a passphrase you’ll remember), once for the secret.
How the session cookie works
Section titled “How the session cookie works”On POST /api/cms/auth/login with the right password:
- The server builds a payload:
{ exp: now + 12h }. - Base64url-encodes it.
- Signs with HMAC-SHA256 using
CARET_SESSION_SECRET. - Sets
caret_session=<payload>.<sig>asHttpOnly; SameSite=Lax; Path=/; Secure(Secure only over HTTPS).
On every authenticated request, the server:
- Reads the cookie.
- Splits payload and signature.
- Re-signs the payload with the secret.
- Compares the two signatures with
crypto.timingSafeEqual. - Verifies
exp > now.
Verification was hardened to:
- Decode signatures from base64url (rejects malformed input cleanly)
- Refuse empty signatures (would otherwise compare against zero-length expected)
- Catch decode errors and reject (rather than throw)
There’s a regression test at tests/unit/auth-token.test.ts.
CSRF defense
Section titled “CSRF defense”Cookies use SameSite=Lax, which blocks cross-origin top-level POSTs. But same-origin XSS (or any future cross-origin fetch with credentials) could still post. CaretCMS adds a second layer:
Routes that enforce it:
POST /api/cms/mutatePOST /api/cms/historyPOST /api/cms/upload
Why this works:
| Mechanism | What it blocks |
|---|---|
| Custom header on cross-origin fetch | Forces a CORS preflight that fails (you don’t ship permissive CORS) |
| HTML form action | Forms can’t set custom request headers |
| Same-origin XSS | Could still set the header — but if you have XSS, you’re already compromised |
The shipped editor sets the header automatically. Custom clients need to add it:
curl -X POST http://localhost:4321/api/cms/mutate \ -H 'Content-Type: application/json' \ -H 'x-caret-request: 1' \ -H 'Cookie: caret_session=<token>' \ -d '{ ... }'await fetch('/api/cms/mutate', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'x-caret-request': '1', }, body: JSON.stringify({ /* mutation */ }),});Missing it returns:
{ "error": "Missing required request header", "detail": "Send 'x-caret-request: 1' on mutating CMS requests."}with HTTP 403.
Auth endpoints
Section titled “Auth endpoints”| Route | Method | Purpose |
|---|---|---|
/api/cms/auth/login | POST | { password } — sets the session cookie |
/api/cms/auth/session | GET | { authenticated: boolean } — checked by the editor bootstrap |
/api/cms/auth/logout | POST | Clears the session cookie |
| Detail | Value |
|---|---|
| Session cookie name | caret_session |
| Default TTL | 12 hours |
| Override TTL | CARET_SESSION_TTL_HOURS |
Production checklist
Section titled “Production checklist”Run through this before any production deploy:
-
CARET_EDIT_PASSWORDset to a long random value (or removed if you’re disabling editing in prod) -
CARET_SESSION_SECRETset to a different long random value — never commit it - HTTPS only — Secure cookies require it; logged-in editors leak session tokens otherwise
-
output: 'server'(or hybrid with prerendering off on/adminand/api/cms) - Don’t expose the editor on a public preview URL without lockdown — the entire editor is gated by one shared password
- If you don’t want editing in production, set
enableAdmin: falseandenableInlineEditor: false
If you want editing on production but not on a public preview, set enableAdmin and enableInlineEditor from import.meta.env so they vary by environment:
caret({ enableAdmin: import.meta.env.MODE === 'editing', enableInlineEditor: import.meta.env.MODE === 'editing',})Threat model
Section titled “Threat model”- Drive-by CSRF (custom header forces preflight)
- Session forgery (HMAC + secret + timing-safe compare)
- Password brute-force on a network with rate limiting (the server itself doesn’t rate-limit — put a layer in front)
- Prototype-pollution attempts in field paths (rejected at the mutation layer)
- A leaked
CARET_EDIT_PASSWORD— anyone with it can edit. Rotate by changing the env var. To invalidate existing sessions, also changeCARET_SESSION_SECRET. - XSS — if attacker JS runs in your editor’s browser, all bets are off.
- A leaked
CARET_SESSION_SECRET— anyone can forge sessions. Treat it like a private key. - Network-level attacks — use HTTPS and a CDN/WAF.
Rotating credentials
Section titled “Rotating credentials”To kick all editors out and invalidate every session:
- Change
CARET_SESSION_SECRET(existing cookies fail signature verification). - Optionally change
CARET_EDIT_PASSWORD(forces a fresh login). - Redeploy.
There’s no per-user revocation because there are no users.