Skip to content

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.

.env
CARET_EDIT_PASSWORD=long-random-passphrase
CARET_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:

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: { editor: true, editorId, 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.
  6. 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.

Server deployments can delegate authentication to an existing service without adding a runtime dependency to core:

astro.config.mjs
import caret, { defineIdentityProvider } from '@caretcms/core';
caret({
identity: defineIdentityProvider({
entrypoint: './src/caret-identity.ts',
exportName: 'identityProvider',
options: { loginOrigin: 'https://login.example.com' },
}),
});
src/caret-identity.ts
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.

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
  • POST /api/cms/publish
  • DELETE /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:

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.

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)

Run through this before any production deploy:

Static delivery (CDN)

  • caret({ delivery: 'static' }) configured
  • CARET_EDIT_PASSWORD and CARET_SESSION_SECRET set 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_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' (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: 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: login is rate-limited in-process to 5 failures per 15 minutes per client, returning 429 with a Retry-After header. 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)

To kick all editors out and invalidate every session:

  1. Change CARET_EDIT_PASSWORD.
  2. Change CARET_SESSION_SECRET (existing cookies fail signature verification).
  3. 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.