Authentication and security
CaretCMS supports a shared editor password or an authoritative external identity provider. Both paths use the same authorization boundary and CSRF protection for mutations.
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) |
CARET_TRUST_PROXY |
Set to true when behind a reverse proxy (Nginx, Cloudflare, a load balancer) so the Secure cookie flag is derived from X-Forwarded-Proto instead of the direct connection |
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:
{ editor: true, editorId, 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. - Requires a valid
editorId, which keys that session’s private draft overlay.
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.
External identity provider
Section titled “External identity provider”Server deployments can delegate authentication to an existing service without adding a runtime dependency to core:
import caret, { defineIdentityProvider } from '@caretcms/core';
caret({ identity: defineIdentityProvider({ entrypoint: './src/caret-identity.ts', exportName: 'identityProvider', options: { loginOrigin: 'https://login.example.com' }, }),});import type { IdentityAdapter } from '@caretcms/core';
export function identityProvider(options: { loginOrigin: string }): IdentityAdapter { return { async authenticate(request) { // Verify your trusted session or proxy-authenticated request here. return { id: 'editor_01', name: 'Alex Rivera', roles: ['editor'] }; }, loginUrl({ redirectTo }) { return `${options.loginOrigin}/login?returnTo=${encodeURIComponent(redirectTo)}`; }, logoutUrl({ redirectTo }) { return `${options.loginOrigin}/logout?returnTo=${encodeURIComponent(redirectTo)}`; }, };}authenticate() returning an identity grants editor access; returning null
denies it. IDs must match /^[A-Za-z0-9_-]{1,64}$/. When configured, the
identity provider is authoritative: Caret never falls back to
CARET_EDIT_PASSWORD. Authentication errors and unsafe IDs fail closed.
The identity is returned by /api/cms/auth/session, keys the editor’s private
draft, appears in Studio, and is attached to new history snapshots. Only trust
identity headers when a proxy removes client-supplied copies and writes its own.
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/uploadPOST /api/cms/publishDELETE /api/cms/draft
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 mode: { password } sets the session cookie. Returns 409 when external identity is authoritative. |
/api/cms/auth/session |
GET |
{ authenticated, identity } — checked by the editor bootstrap |
/api/cms/auth/logout |
POST |
Clears the Caret cookie and returns/redirects to the provider’s logout URL when configured |
| Detail | Value |
|---|---|
| Session cookie name | caret_session |
| TTL | 12 hours (fixed) |
Production checklist
Section titled “Production checklist”Run through this before any production deploy:
Static delivery (CDN)
-
caret({ delivery: 'static' })configured -
CARET_EDIT_PASSWORDandCARET_SESSION_SECRETset where authoring runs (dev/staging) - CI rebuilds after publish and has access to
.caret/data/ - Production deploy is static HTML only — no editor routes in
dist/
Server delivery
-
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'(the integration skips install on static output) - Use a suitably strong shared password or configure an authoritative identity provider
- 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: login is rate-limited in-process to 5 failures per 15 minutes per client, returning
429with aRetry-Afterheader. The counter lives in process memory, so it isn’t shared across instances or Workers isolates — add a WAF or edge rate-limit in front for multi-instance deploys. - Prototype-pollution attempts in field paths (rejected at the mutation layer)
- A leaked
CARET_EDIT_PASSWORD: anyone with it can edit in password mode. Rotate it together withCARET_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_EDIT_PASSWORD. - Change
CARET_SESSION_SECRET(existing cookies fail signature verification). - Redeploy every instance with both values.
Shared-password mode has no per-user revocation. External identity providers can
apply their own user/session revocation before authenticate() grants access.