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.
Choose a sign-in method
Section titled “Choose a sign-in method”| Need | Use |
|---|---|
| A small trusted group sharing one password | Built-in password login |
| Named editors or an existing sign-in service | An external identity provider |
| Different edit/publish permissions per person | External identity plus an authorize policy |
| A disposable public editing demo | Sandbox mode with supported storage |
Start with password login for local setup. Use a stable external identity when editors must reopen private drafts after signing out and back in. A shared password does not give each person a named account or a stable draft identity.
Authentication decides who can sign in. Permissions decide which writes they can perform. Returning roles from an identity provider does not enforce those permissions by itself.
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 only when your trusted reverse proxy sets X-Forwarded-Proto and removes client-supplied copies |
For local Node development, load the file before starting Astro:
node --env-file=.env ./node_modules/astro/bin/astro.mjs devThis starts Astro directly with the file’s variables available in process.env. Restart an already-running dev server after changing credentials.
Astro’s import.meta.env loading is not a substitute for these runtime variables.
For production, use your host’s environment/secret settings; a built Node server
can also be started with node --env-file=.env ./dist/server/entry.mjs when that
file is securely managed on the server.
Use Node’s --env-file with
the script path, as above. Combining it with node --run does not pass the
file’s variables to the package script.
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”A successful password login sets a signed caret_session cookie that lasts
12 hours. It carries an editor-session ID used to locate private drafts.
The cookie is HttpOnly (page JavaScript cannot read it), SameSite=Lax, and Secure
when the request uses HTTPS.
The server verifies its HMAC-SHA256 signature and expiration before accepting a
request. Changing CARET_SESSION_SECRET invalidates existing password sessions.
External providers manage their own session lifetime and revocation.
Before signing out or rotating credentials, resolve saved drafts and unsaved work. See Saving and publishing.
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';import { verifyEditorSession } from './auth/verify-editor-session';
export function identityProvider(options: { loginOrigin: string }): IdentityAdapter { return { async authenticate(request) { // Implement this using your existing authentication service. const user = await verifyEditorSession(request); if (!user) return null; return { id: user.id, name: user.name, roles: user.roles }; }, loginUrl({ redirectTo }) { return `${options.loginOrigin}/login?returnTo=${encodeURIComponent(redirectTo)}`; }, logoutUrl({ redirectTo }) { return `${options.loginOrigin}/logout?returnTo=${encodeURIComponent(redirectTo)}`; }, };}verifyEditorSession is a project-owned server function, not a CaretCMS export.
It must validate an existing session before returning a user.
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.
Restrict write permissions
Section titled “Restrict write permissions”Add authorize to the identity adapter to control edit, publish, delete,
collection management, and upload. Roles alone do not enforce restrictions.
An active policy sends all content edits to private drafts; it does not restrict
authenticated reads. See Permissions for a complete example
and the limits of draft sharing.
CSRF defense
Section titled “CSRF defense”Cross-site request forgery (CSRF) is an attempt to make a signed-in browser
submit an unwanted write from another site. Caret’s content-write routes require
x-caret-request: 1 in addition to authentication:
POST /api/cms/mutatePOST /api/cms/historyPOST /api/cms/uploadPOST /api/cms/publishDELETE /api/cms/draft
An ordinary HTML form cannot set this custom header. A cross-origin fetch with it requires a CORS preflight; do not configure permissive credentialed CORS for these routes. SameSite cookies provide another layer. This protection does not stop malicious JavaScript already running on your site’s origin.
Login and logout have their own behavior; the header rule above applies to content writes, not every POST request.
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 publication and receives the configured content root and uploaded files
- Production deploy is static HTML only — no editor routes in
dist/
Server delivery
- Password mode: set a strong
CARET_EDIT_PASSWORD - Password mode: set a separate random
CARET_SESSION_SECRET— never commit it - HTTPS only — Secure cookies require it; logged-in editors leak session tokens otherwise
-
output: 'server'and the appropriate Astro host adapter - Use a suitably strong shared password or configure an authoritative identity provider
- Use authentication/policy or host access controls to restrict API writes. UI toggles alone do not disable the API.
To show editing UI only in a selected environment, set enableAdmin and
enableInlineEditor from import.meta.env. These switches hide UI, not API routes:
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 invalidate all built-in password sessions:
- 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.