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.

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.

.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 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:

Terminal window
node --env-file=.env ./node_modules/astro/bin/astro.mjs dev

This 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:

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.

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.

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';
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.

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.

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

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 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',
})
  • 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 invalidate all built-in password sessions:

  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.