Skip to content

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.

.env
CARET_EDIT_PASSWORD=long-random-passphrase
CARET_SESSION_SECRET=different-long-random-string
VariableWhat it does
CARET_EDIT_PASSWORDChecked on POST /api/cms/auth/login with constant-time compare
CARET_SESSION_SECRETSigns session cookies — always set in production
EDIT_PASSWORDFallback for backward compat (prefer the prefixed version)

Generate random values:

Terminal window
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.

On POST /api/cms/auth/login with the right password:

  1. The server builds a payload: { exp: now + 12h }.
  2. Base64url-encodes it.
  3. Signs with HMAC-SHA256 using CARET_SESSION_SECRET.
  4. Sets caret_session=<payload>.<sig> as HttpOnly; SameSite=Lax; Path=/; Secure (Secure only over HTTPS).

On every authenticated request, the server:

  1. Reads the cookie.
  2. Splits payload and signature.
  3. Re-signs the payload with the secret.
  4. Compares the two signatures with crypto.timingSafeEqual.
  5. 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.

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/mutate
  • POST /api/cms/history
  • POST /api/cms/upload

Why this works:

MechanismWhat it blocks
Custom header on cross-origin fetchForces a CORS preflight that fails (you don’t ship permissive CORS)
HTML form actionForms can’t set custom request headers
Same-origin XSSCould 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:

Terminal window
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 '{ ... }'

Missing it returns:

{
"error": "Missing required request header",
"detail": "Send 'x-caret-request: 1' on mutating CMS requests."
}

with HTTP 403.

RouteMethodPurpose
/api/cms/auth/loginPOST{ password } — sets the session cookie
/api/cms/auth/sessionGET{ authenticated: boolean } — checked by the editor bootstrap
/api/cms/auth/logoutPOSTClears the session cookie
DetailValue
Session cookie namecaret_session
Default TTL12 hours
Override TTLCARET_SESSION_TTL_HOURS

Run through this before any production deploy:

  • CARET_EDIT_PASSWORD set to a long random value (or removed if you’re disabling editing in prod)
  • CARET_SESSION_SECRET set 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 /admin and /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: false and enableInlineEditor: 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',
})
  • 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)

To kick all editors out and invalidate every session:

  1. Change CARET_SESSION_SECRET (existing cookies fail signature verification).
  2. Optionally change CARET_EDIT_PASSWORD (forces a fresh login).
  3. Redeploy.

There’s no per-user revocation because there are no users.