Operations › Security
Introduction
Content
Using content
Plugins
Operations
Recipes
Security
What you'll learn
What Easy CMS protects for you, what stays your job, and a checklist before going live.
Before this page: Access control and Users & auth.
What Easy CMS does for you
Accounts and sessions
- Passwords are hashed with scrypt (N=2¹⁷) and never returned by any API. They must be at
least 8 characters, and not one of the ~2,000 most common passwords (such as
password1oriloveyou). - Session tokens are random, signed with
EASY_CMS_SECRET, and stored only as hashes, so a copy of the database does not let anyone log in. - Cookies are
HttpOnly,SameSite=Lax, andSecurein production (or over HTTPS). Over HTTPS (or in production) the session, CSRF and SSO cookies are named with the__Host-prefix (__Host-ecms-session,__Host-ecms-csrf,__Host-ecms-sso), so only this exact host can set them, not a subdomain. Over plain HTTP in development they keep the plain names. Cookies under the old plain names are still read, and logging out clears both. - Logins are rate-limited: after 5 failures for an email (and IP, when known) within 15
minutes, login answers
429(auth.maxLoginAttempts,auth.lockWindow). - Logging out, changing the password or deactivating a user ends all of their sessions.
- Logging out answers with
Clear-Site-Data: "cache", so the browser drops cached pages. It is not"storage", because your site may share the origin and keep its own data (such as a guest's cart). The admin also forgets what it kept in the browser about content: unsaved drafts, recent documents, the last media folder and list positions. Preferences such as the theme stay.
Requests
- CSRF: a write authenticated by the session cookie must send the session's CSRF token in
the
x-csrf-tokenheader, and itsOriginmust be the API's own or inauth.trustedOrigins. Cross-site requests without anOriginare refused. Requests with a Bearer token skip the token check (a browser never sends one on its own). - CORS is closed by default.
corslists origins whose browser code may call the API; onlyauth.trustedOriginsmay send cookies. Origins incorsmay also write without a session cookie (e.g. a public form): there is no session to forge, and the collection's access rules still decide. - Rate limits (
rateLimit), when turned on: each visitor's IP, signed-in user and API key may send so many requests a minute, counted across instances. Over that, the API answers429withRetry-After. See below. - Request bodies are limited to 1 MB (JSON) and
upload.maxFileSize(files, 10 MB). - HSTS: in production (
NODE_ENV=production) the REST API and the admin sendStrict-Transport-Security: max-age=31536000(withoutincludeSubDomains), so browsers use HTTPS only for a year. Browsers ignore it over plain HTTP. - Errors show their details in development only; in production unexpected errors answer
Internal Server Errorand are logged on the server.
Content
- Access is closed by default: without rules, only logged-in users can read or change a collection. Field-level rules remove fields from responses and ignore them in input.
- Uploads are checked by their content (not the file name), limited in size, renamed, and
served with
Content-Security-Policy: sandboxandnosniff, so an uploaded SVG or HTML file can't run scripts on your site. - Rich text rendering (
renderRichText) escapes text and attributes and drops unsafe URLs such asjavascript:. - Preview links carry a signed token for one document or global that expires after an hour.
The admin
- Admin pages send a strict Content Security Policy (scripts only from your own origin),
X-Frame-Options: DENYandReferrer-Policy: same-origin. - Admin modules are served by your server to logged-in users only; URLs of other sites are refused in the config.
What stays your job
- Keep
EASY_CMS_SECRETsecret and long (32+ random characters, e.g.openssl rand -hex 32). Rotate it to change it without logging everyone out. - Write access rules on purpose, especially
read: a collection is public only when you say so, e.g.read: () => trueor "published only". - Give people the smallest role they need: editors get
editor, notadmin. Only admins can manage users. - Install plugins you trust. A plugin runs on your server, and its admin components run with the rights of whoever is logged in.
- Review migrations before deploying and keep dependencies updated.
- Back up the database and uploads. See Backups.
Rate limits
rateLimit limits how many requests one client sends to the API in a minute. It protects the
database from a script or a visitor sending far more than people do, and it holds across instances.
export default defineConfig({
rateLimit: {}, // the defaults below
// rateLimit: { window: 60, anonymous: 300, users: 1200, apiKeys: 1200 },
})| Who | Counted by | Default per minute |
|---|---|---|
| Visitors who are not signed in | IP address | 300 |
| Signed-in users (the admin included) | user | 1,200 |
| API keys | key | 1,200 |
false for one of them lifts its limit. Over the limit, the API answers 429 Too Many Requests with
Retry-After, the seconds until the window ends. Health checks (/healthz, /readyz) and
jobs/run aren't counted. It covers everything under the API route, plugin endpoints included,
not the Local API or pages your app renders on the server.
- Turning it on needs a migration, for the
rate-limitstable (easy-cms migrate:create). Projects made withcreate-easy-cms1.19 or later have it on. - Counts are shared through the database every few seconds, not on every request, so the API doesn't write for each read. Between syncs, a client can go a little over, by what the other instances counted meanwhile.
- The IP must be the visitor's. Vercel and Netlify are recognized; behind your own proxy, set
trustProxy. Without an IP, visitors who are not signed in aren't limited (counting them all as one would block everyone at once), and the server log says so once. - No IP is stored: the table keeps an HMAC of it, made with the secret.
- Static builds fetch many pages at once: build with an API key, whose limit is higher (or
apiKeys: false), or from the Local API.
Logins, sign-ups and form submissions keep their own, lower limits.
Rotating the secret
Replace EASY_CMS_SECRET when it may have leaked, or on a schedule, without logging everyone out.
previousSecrets lists the secrets used before: what they signed still verifies, and everything
new is signed with secret.
export default defineConfig({
secret: process.env.EASY_CMS_SECRET ?? '',
previousSecrets: (process.env.EASY_CMS_PREVIOUS_SECRETS ?? '').split(',').filter(Boolean),
// ...
})Projects made with create-easy-cms 1.18 or later have that line; add it to older ones.
- Deploy with both. Make a new secret (
openssl rand -hex 32), set it asEASY_CMS_SECRETand the old one asEASY_CMS_PREVIOUS_SECRETS. Sessions, password and invitation links, preview tokens, links to private files and forms that were open keep working. - Sign the audit log again.
npx easy-cms audit:resignsigns the entries signed with the old secret with the new one. Entries no secret verifies were changed in the database: they are left as they are and listed. - Remove the old secret once what it signed has expired: sessions (
auth.tokenExpiration, 7 days by default) and invitation links (auth.inviteExpiration, 7 days). Sessions still using it then end.
If the old secret leaked, skip the waiting: remove it at once, and everyone signs in again.
- Rate limits of public forms start counting again: their keys come from the secret.
- Not covered:
backups.encryptionKeyis a key of its own; backups made with an old key need that key to be read. API keys don't depend on the secret.
Checklist before going live
-
EASY_CMS_SECRETis set in the server's environment, not only in.envon your laptop. -
NODE_ENV=production, and the site is served over HTTPS. - The site is served over HTTPS only: the proxy or host redirects HTTP to HTTPS.
- Every collection's
readrule is what you intend; test it logged out. -
corsandauth.trustedOriginslist only your own origins. - Behind a proxy you control, enable trust-proxy (
trustProxyfor Nuxt and Next.js,--trust-proxyfor standalone) so rate limiting sees real client IPs: the last address inX-Forwarded-Forcounts. The CSRF origin check then also usesX-Forwarded-Host; without trust-proxy, a proxy that changes theHostmakes admin writes fail with403. Vercel and Netlify are recognized automatically and need nothing. Without an IP, failed logins count per email, and the server log says so. -
EASY_CMS_SECRETis random (openssl rand -hex 32): in production, one that looks like an example or a pattern is refused. -
EASY_CMS_SETUP_CODEis set until the first admin exists (in production the server warns while there is no admin and no code), and creating that admin is in the audit log. -
CRON_SECRETis a random string of at least 32 characters, likeEASY_CMS_SECRET. - The first admin has a strong password; other people have the
editorrole. - Migrations are applied (
easy-cms migrate) and backups run on a schedule.
Reporting a vulnerability
Report vulnerabilities privately as described in SECURITY.md, not in a public issue.
Next steps
- Access control: rules per collection, document and field.
- Migrations & deployment: ship schema changes safely.