Security
Security
On this page
Introduction
This page lists what a Marmeon app protects by itself, what it leaves to you, and where each part is explained. The defaults are chosen to be safe when nobody changes them. Where a protection costs something, such as a dependency, a migration or a cache, it is a switch, and this page says which way it points. The checklist at the end sums up what to do before a server goes live.
Found a vulnerability in the framework? Report it privately, as SECURITY.md describes, never in a public issue.
The environment decides, and fails closed
NODE_ENV is development, test or production; any other value stops the start. Two rules depend on it, and they are not
mirror images of each other:
- Development tools run only with
NODE_ENV=development. The error page with the code that threw, the devtools, theServer-Timingheader, the mail previews and Vite's dev server stay off everywhere else,testincluded. - Protections apply to
productionand to a server where nobody setNODE_ENV. JSON logs,migrateasking for--force, the warnings at start-up and the absence of lab pages hold there.testbelongs to the test runner alone: it turns every protection off, and development-only routes answer under it, so it never belongs on a server.
APP_ENV names the environment (staging, production, a name of your own) and changes neither rule: APP_ENV=local on a
server with NODE_ENV=production is protected, and its development routes answer 404. A staging server is
NODE_ENV=production APP_ENV=staging, with the production builds of the framework and of React, so a server error never reaches
its visitors with a message or a stack.
A forgotten variable therefore never shows your source code to a visitor. Set NODE_ENV in the server's real environment, and
never deploy a developer's .env: marmeon start, queue:work and schedule:work warn loudly when development comes from a
.env, and the start line says development (APP_ENV=local, NODE_ENV from .env). The
configuration page explains where NODE_ENV comes from.
Code that runs in the browser and in server rendering checks for development positively, as development or test, never as
"not production". Without a value, an error in the browser shows only its short code, such as [marmeon:R2] users/Show, no
development warning appears, and an error page rendered on the server carries no exception message.
Development routes
The devtools at /_marmeon/devtools and the mail previews at /_marmeon/mail exist only with NODE_ENV=development. They sit
behind one host guard, devOnly() of @marmeon/core/dev: it answers localhost, 127.0.0.1, [::1] and the host of APP_URL,
and gives every other Host a 403 before the handler runs. Every answer is no-store and nosniff, carries no CORS header, and
is shared with no other origin, neither as a resource nor as a window.
The attack it stops is DNS rebinding. A page on evil.example points its own name at 127.0.0.1, reaches the developer's server
with Host: evil.example, and reads the answers as if they were its own: recorded requests, mails with reset links, source paths.
marmeon devputs Vite in front, and Vite refuses unknown hosts itself. It does not under HTTPS, withserver.httpsinvite.config.ts, and not withallowedHosts: true. Then the guard alone protects the devtools and the mail previews, while Vite's own paths, such as source modules, and the development error page have no host check. Use either setting only on a network you trust, and keepallowedHostsa list. The asset bundling page covers Vite's settings.- A development route of your own takes the same guard. Outside
developmentandtestit answers 404 by itself, asked on every request: a route registered on a server by mistake stays shut. The routing page shows how. *.localhostnames pass Vite, since they are loopback by definition, but not the guard, unlessAPP_URLnames one.
Requests from other sites
Every request of the web group that may change something is checked for cross-site request forgery. The browser's
Sec-Fetch-Site: same-origin passes, and a request from another site is refused with 403. Without Fetch Metadata, the Origin
header decides. Only a client that sends neither needs the session's token, and without it gets 419.
- Guests get no session just for a token. A session draws its token only when something needs it, and signing in or out renews it. A guest who only reads leaves no cookie behind, unless the page keeps a session, such as one with an upload field.
- Browsers send Fetch Metadata to secure origins only. Over plain HTTP the check falls back to
Origin, then to the token. Serve production over HTTPS. - Trust other origins with
CSRF_TRUSTED_ORIGINS, and exclude webhooks that cannot carry a token with a module'scsrfExcept. - The
apigroup has no session and no CSRF check, since every request proves itself with its API token.
The CSRF protection page explains every stage.
Sessions and cookies
- Every cookie is
SecurewhenAPP_URLstarts withhttps://: the session cookie,XSRF-TOKEN, the remember-me cookie and your own.SESSION_SECURE_COOKIEoverrides it either way. A protected app whose cookies go withoutSecurewarns at start-up. The session page has the details. - Cookies are encrypted with
APP_KEYin thewebgroup, all butXSRF-TOKEN, which the browser's JavaScript reads. The session cookie is alsoHttpOnly. - The store never sees a session id. A session is kept under the SHA-256 hash of its id, so a leaked file, row or Redis key is
no cookie.
SESSION_ENCRYPT=trueencrypts the data as well. The session page explains both. - Signing in or out renews the session: a new id and a new CSRF token, so an id handed out before signs nobody in.
- Signed-in answers are
Cache-Control: no-store, private, so Back after signing out shows nothing from the browser's cache. Signing out also sendsClear-Site-Data, and the other tabs of the browser follow by themselves. The authentication and client features pages explain them. - Requests that only read never write. Islands, live streams and the tabs' session checks write no session data and set no cookie. The request lifecycle page lists them.
- Use a session driver on the server for an app with sign-in:
file,databaseorredis. Thecookiedriver cannot protect a sign-out against a request that was still running.
Keys
APP_KEY encrypts cookies, encrypted jobs and sealed sessions, and the keys that sign URLs, live channels and uploads are derived
from it. Anyone who has it can read and forge all of them. Generate it once per environment with marmeon key:generate, keep it
out of the repository and out of the image, and rotate it with APP_PREVIOUS_KEYS, so that old cookies and signed links stay valid
until they expire. The encryption page explains the key and its rotation.
Pages and scripts
- Content Security Policy.
contentSecurityPolicy()gives every document a policy with a fresh nonce: scripts only with that nonce, and the chunks they load, no plugins, no foreign<base>. Every new app turns it on. A nonce must never reach a shared cache: the app warns when a publicly cached page carries one. The Content Security Policy page explains the policy. - Styles without
'unsafe-inline'. The framework renders nostyleattribute, sostyleSrc: ["'self'"]blocks nothing of its own in a built app. Your ownstyleprops need classes then, and@marmeon/react/styles.cssis imported in the client entry. The styling page shows how. - Escaping. React escapes what pages render. HTML you write by hand goes through the
htmltemplate of@marmeon/core, as the responses page shows. Page data travels as JSON in an inert<script type="application/json">. - Security headers. A new app's
middleware/SecurityHeaders.tssendsnosniff, a strictReferrer-PolicyandX-Frame-Options: DENYon every response. Its switch forStrict-Transport-Securityis off until you turn it on, once the site answers over HTTPS alone. The Content Security Policy page lists them. - Behind a proxy,
X-Forwarded-*headers count only from the addresses inTRUSTED_PROXIES. Otherwise any client could choose its own address, for rate limits and logs, and its own scheme.*trusts the direct peer only, and is safe only when nothing but your proxy can reach the app. The deployment page explains the values. - No compression of documents. A compressed page that holds a secret next to text an attacker controls leaks the secret byte by byte. The framework compresses assets at build time and never pages; leave pages uncompressed at the proxy too, as the deployment page says.
Accounts
- Passwords are hashed with Argon2id, within bounds that a planted hash cannot exceed. The hashing page explains the drivers and costs.
- Rate limits. The starter kit limits the sign-in per address and client together, the registration per client, reset mails per client and per address, and a signed-in user's password checks per user: the confirmation, the password change and "sign out other devices". The password change and "sign out other devices" run one at a time per session. The rate limiting page covers limiters.
- A sign-in tells nothing. An unknown address and a wrong password get the same answer in the same time. The authentication page explains how.
- Validation never tells whether a record exists while a field is typed. The
uniqueandexistsrules and an asynccustom()do not run in a live validation unless the rule says.live(), which a rule that asks about accounts must never do. Without it, a free and a taken address get the same answer there, and only a submit tells, behind its rate limit. A schema of another library runs live only when its request sayslive: true, which a form that asks about accounts must never say either. The unique index stays the truth:failOnDuplicate()turns its conflict into a 422. The validation page explains why. - Mail goes out encrypted or not at all. SMTP uses TLS from the first byte, or requires STARTTLS, so a server without it, or
someone who strips the offer, never sees the credentials or a reset link.
MAIL_REQUIRE_TLS=falseturns the requirement off, for a local relay only. The mail page has the settings. - Policies decide what a user may do, on every request: the session's user on a page, the token's user on a route behind
authenticate('api'). Authorize an API route with the token's abilities and a policy. An ability only says what kind of action a token may do, never on whose record, so it alone leaves every user's records one id away. The authorization page shows both on one route.
Private registration
The registration of the starter kit never tells whether an address has an account. This is the default:
- Every registration gets the same answer in the same time. The owner of a known address gets a mail instead of a second account.
- Registering signs nobody in. The auth module's pages ask for a confirmed address, so whoever registered with somebody else's
address sees only a notice, and the owner takes the account over with a password reset. Your own routes need
verified()for the same: the email verification page explains it. - A change of address waits until the link sent to the new address is opened, with the same answer for a free and a taken address.
Open registration
AUTH_PRIVATE_REGISTRATION=false turns all of this off. The registration and the change of address then say "taken", a new account
is signed in at once, and a new address takes effect at once. From then on only the registration's rate limit stands between the
form and a list of who has an account. Outside development and tests the app logs a warning about it at every start. Turn it off
only when the accounts of your app are public anyway. The starter kit page shows the switch.
An unconfirmed account is then a working account. Whoever registers with somebody else's address uses it at once: the module's
ConfirmedAddress lets every signed-in user through, the tokens page included, which asks only for the password they chose. When
the owner later takes the account over with a password reset, its sessions and remember-me tokens end. An API token created in the
meantime ends with them only with AUTH_REVOKE_TOKENS_ON_PASSWORD_CHANGE=true; without it, it stays valid, up to 90 days with the
starter kit. With open registration, set AUTH_REVOKE_TOKENS_ON_PASSWORD_CHANGE=true and put verified() in place of
ConfirmedAddress on the token routes.
Accounts nobody confirmed
auth:prune-unverified deletes accounts whose address was never confirmed, AUTH_UNVERIFIED_DAYS days after the sign-up, 7 by
default. An account confirmed once is never deleted. The command runs only when your app turns its line on in
modules/auth/schedule.ts, because deleting accounts is your app's decision. Once it runs, whoever registered with somebody
else's address stops holding it, and nothing of an unfinished sign-up stays: the account, its tokens and what other modules keep
go, and its sessions end. With open registration, such an account was signed in and may have used the app before it goes. Without
the line, these accounts stay: decide it for your app. The email verification page
explains the rules.
Tables and exports
A table of @marmeon/table is one definition the server enforces on every request. These are the attacks of hand-written tables,
and what stops each:
- Hidden columns stay hidden. A column the policy hides is not selected: not in the SQL, so not in the props, the devtools' query log, a cursor or an export. The props are picked by name besides.
- No sort oracle. Sorting, filtering or searching by a column the user cannot see is a 400, as is any sort, filter or page size
the definition does not name, such as
?sort=passwordor?per_page=10000. An order by a secret would tell its values without showing them. Nothing from the URL reachesORDER BYorWHEREthat the definition did not name. - No bulk IDOR. A bulk action acts on the keys sent, within the base query, and only on rows the policy allows. Another
tenant's key or a made-up one is left out as missing, and a row the policy refuses is skipped. "All matching" sends the filter,
never keys, and the server refuses more rows than the action's
maxwith 422. Put the tenant into the base query: every endpoint starts from it. The endpoints take the middleware of the page's route registrar, not the page controller'sauthorize, so a table that needs one declares it. - No CSV injection. Exported text cells that start with
=,+,-,@, a tab or a line break get a'in front, and every field is quoted. An export has its ownauthorizeand a row limit, 10 000 by default, beyond which it answers 422 before the first byte. Rate-limit it as well: it reads the whole filtered table. - Keys are parsed by their declared type before any query, so text never reaches an integer column.
The cursors of cursorPaginate() and of cursor tables are encrypted with the app key: a hidden sort column's value never shows in
a URL, and a changed or made-up cursor is a 400. The tables and
pagination pages explain both.
Live updates
Live props send hints, never data. A tab that gets a hint reloads through the page's route, its middleware and its policies, so a
hint can never show what the user may not see. A subscription is signed for its session, and another channel or another session's
token answers 403. Limits keep one user from exhausting a process: at most 16 streams per session
(LIVE_MAX_STREAMS_PER_SESSION, beyond it 429 and a warning in the log), 1000 per process (LIVE_MAX_STREAMS, beyond it 503) and
20 channels per stream. The limit per session counts by session, not by address; a flood of fresh sessions meets the limit per
process, so limit connections at the proxy as well. The real-time updates page explains them.
Logs and recordings
The log masks the values of keys that look like secrets, such as password, token, cookie or authorization, in nested
objects too, and the names packages add to the app's Redactor, such as the session cookie's. A URL in the context, and the
message and stack of an error under any key, are masked as text. Every other string, the message included, is written as it is,
so never put a secret into one. The logging page lists the rules, and
the app's Redactor for text you must log.
The devtools' recordings, the development error page and traces go further: the same Redactor also masks every string there.
The devtools exist in development only and refuse other hosts. The logging page
explains what each shows.
Traces
With @marmeon/otel, a request from outside starts a trace of its own, and an incoming traceparent header is only a link to the
caller's span. A client cannot put its requests into a trace it chose, nor switch their recording on or off with the sampled flag,
and its baggage is dropped. OTEL_TRUST_TRACEPARENT=true continues the caller's trace: set it only behind an edge that sets or
strips the header, such as a gateway, an ingress or a mesh. TRUSTED_PROXIES is no reason, since a proxy usually passes the
client's header on unchanged. The observability page covers tracing.
Your checklist
NODE_ENV=production, or another protected value, in the server's environment, and no developer's.envon the server.APP_URL=https://…, and a freshAPP_KEYper environment, kept in the server's secrets.contentSecurityPolicy()inbootstrap/app.ts, which a new app has. New inline scripts takectx.nonce(). With astyleSrc, classes instead ofstyleprops, and@marmeon/react/styles.cssimported in the client entry.verified()afterauthenticate()on every route of your own that signed-in users reach.marmeon devover HTTPS or withallowedHosts: trueonly on a trusted network. Staging private, since it shows server errors.- Whether unconfirmed accounts go: the
auth:prune-unverifiedline inmodules/auth/schedule.ts, andAUTH_UNVERIFIED_DAYSas you want it, 7 by default. WithAUTH_PRIVATE_REGISTRATION=false, tell your users that an unconfirmed account goes, and setAUTH_REVOKE_TOKENS_ON_PASSWORD_CHANGE=true. abilities()on every API route that acts: without it, every token of a user acts with all of the user's rights.OTEL_TRUST_TRACEPARENTonly behind an edge that owns the header, andOTEL_RECORD_QUERY_VALUESonly when your URLs carry no personal data.- HSTS once the site answers over HTTPS only: the switch in
middleware/SecurityHeaders.ts, or the proxy. TRUSTED_PROXIESset to exactly your proxy, or*for the direct peer when nothing else can reach the app. The proxy setsX-Forwarded-*itself and does not compress pages.- Sessions in
file,databaseorredis, notcookie, for an app with sign-in.SESSION_ENCRYPT=truewhen the store is shared. - Tables: the tenant in the base query,
tableRouteson the page's registrar, anauthorizefor exports, athrottleon the table's routes. - Update the framework's packages together and read UPGRADING.md. Security fixes go to the latest minor version, as the releases page explains.