-
Notifications
You must be signed in to change notification settings - Fork 7
Security
Summary: StartER implements essential protections to secure your web application against common vulnerabilities (CSRF, XSS, brute force attacks).
Web security is a vast field, and it's normal to feel overwhelmed at first. StartER's goal is not to cover everything, but to equip you with a solid foundation and help you understand why these protections exist.
Tip
If you're discovering these concepts, read this page in order: each section builds on the previous ones. Technical terms are defined in the Technical glossary.
StartER integrates several security middlewares in server.ts, activated before any routes:
Helmet automatically configures HTTP security headers like Strict-Transport-Security, X-Content-Type-Options, X-Frame-Options, and Content-Security-Policy.
app.use(
helmet({
contentSecurityPolicy: isProduction,
}),
);Note
The Content-Security-Policy is disabled in development because the Vite dev server uses WebSockets and dynamic module evaluation, which are blocked by Helmet's default CSP. In production, the CSP is enabled with Helmet's default values.
express-rate-limit protects against brute force attacks and abuse:
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
limit: 100, // max 100 requests per window
});
app.use(limiter);This configuration is intentionally simple and should be adjusted according to deployment needs.
cookie-parser is registered in src/express/routes.ts. It parses cookies from incoming requests and makes them accessible via req.cookies. This middleware is essential for authentication (cookie __Host-auth) and CSRF validation (cookie __Host-x-csrf-token).
In production only, compression is enabled to reduce the size of HTTP responses:
if (isProduction) {
const compression = (await import("compression")).default;
app.use(compression());
}In the StartER architecture:
- The API is stateless,
- The server does not maintain any back-end sessions,
- StartER does not explicitly configure CORS. By default, no cross-site requests are allowed, because the server does not return any
Access-Control-Allow-Origin. - The front end is served on the same domain as the back end: this is the only domain authorized to communicate with the API,
- Cookies are set to
SameSite=strict.
This already blocks the majority of classic CSRF attacks, since a third-party site can neither send strict cookies nor interact with the API.
StartER uses additional protection, especially for mutative requests (POST, PUT, PATCH, DELETE): the Client-Side Double-Submit pattern.
Tip
The pattern is particularly well explained in the FAQ of the csrf-csrf package, along with other answers regarding CSRF attacks, the necessity of implementing protections, etc.
The pattern is also mentioned, among other places, in the Symfony documentation.
To go deeper, we recommend reading the OWASP documentation.
The front end generates a CSRF token using the csrfToken() utility in src/react/helpers/mutate.ts:
- The token is stored in a cookie
__Host-x-csrf-token(see OWASP documentation about cookie prefixes), - It expires after 30 seconds,
- but each use extends its expiration, replicating the behavior of a client-side session cookie,
- No server storage is required.
This mechanism allows us to say "the CSRF session is active as long as there is activity," while having an explicit timeout to prevent expired tokens from being stored for too long.
Each mutative request includes a header containing the same value as the one stored in the cookie: this is the "double-submit".
X-CSRF-Token: <token>
The server:
- intercepts POST/PUT/PATCH/DELETE requests,
- reads the
__Host-x-csrf-tokencookie, - compares it with the
x-csrf-tokenheader, - responds with a
401status code if the header is missing or if there is an inconsistency between the header and the cookie.
The server remains entirely stateless: it simply compares two values transmitted by the client, without maintaining a session or storing a token on the back end.
This approach is acceptable as long as:
-
The API is not exposed to third parties (no cross-site access allowed).
-
SameSite=stricteliminates the possibility of a third-party site sending the necessary cookies.
When testing mutative API endpoints (POST, PUT, PATCH, DELETE) with tools like Postman or Insomnia, you will encounter a 401 error due to the missing CSRF token.
Because StartER uses a stateless Double-Submit pattern, testing is actually very easy. You simply need to provide matching values in the cookie and the header:
- Add a Header to your request:
x-csrf-token: test-token - Add a Header to your request:
Cookie: __Host-x-csrf-token=test-token(or configure it via your tool's Cookie Manager)
The server checks that the header and the cookie match. As long as you provide the exact same value in both places, the request will pass CSRF validation.
The core of XSS protection lies on the front end:
- no insecure interpolation is performed in the DOM,
- React prevents HTML injection by default,
- StartER does not use
dangerouslySetInnerHTMLanywhere.
On the backend:
- JSON responses are served with a
Content-Type: application/json, which prevents them from being interpreted as executable HTML or JavaScript, - all data stored in the database is treated as opaque content on the frontend.
For projects that want to go further:
- customize the Content Security Policy (CSP) enabled by Helmet in production,
- block all inline scripts,
- only allow explicit sources for scripts and styles.
Tip
Helmet's default CSP is a good starting point. See the Helmet documentation to customize directives according to your needs.
StartER uses two main cookies:
-
__Host-auth: a signed JWT token containing the user's identity -
__Host-x-csrf-token: an ephemeral CSRF token renewed client-side
Both are:
- set to
SameSite=strict - set to
Path=/ - prefixed with
__Host-(see OWASP documentation about cookie prefixes)
The __Host-auth cookie is set to HttpOnly, making it inaccessible to client-side JavaScript code. This is not the case for the CSRF token, which is written client-side.
The JWT stored in a cookie does not represent a session but a signed attestation. Its lifespan is set to 7 days with a sliding window (renewed on each authenticated request) to balance security and user experience in the magic link authentication model.
The JWT is not encrypted, only signed. It contains data that is readable by the client, but protected against modification.
Key points:
- The server does not store session state,
- Revocation occurs via expiration or an explicit request to the server to delete the cookie (logout).
StartER does not serve any content from a third-party domain or a different subdomain.
This drastically reduces the risk of CSRF attacks and attacks related to requests originating from other websites.
The absence of a server-side session limits the risks of:
- session fixation,
- incomplete cleanup,
- memory overflow when tracking long sessions.
By default, StartER does not process file uploads.
If added, it must be filtered, stored outside the root document and validated (type, size...).
Helmet automatically configures essential security headers. Additionally, the production server (Caddy, Nginx, or other) can be configured to reinforce these headers:
-
Strict-Transport-Security: forces HTTPS usage -
X-Content-Type-Options: nosniff: prevents the browser from guessing the MIME type -
X-Frame-Options: DENY: blocks iframe embedding of the page -
Referrer-Policy: strict-origin-when-cross-origin: limits information transmitted via theRefererheader
Errors returned by the backend are intentionally concise:
-
400for malformed requests, -
401for authentication issues, -
403for authorization issues, -
500for errors not handled by the server, - no detailed message explaining the nature of the failure (missing token, invalid signature, expired cookie, etc.).
The frontend then displays clear messages to the user without revealing sensitive information.
Server-side logging is recommended for 401/403 errors, which include:
- Authentication failures,
- Unauthenticated access attempts,
- Forbidden access.
Mutative requests (PUT, POST, PATCH, DELETE) should be systematically logged with:
-
timestamp,
-
authenticated user,
-
endpoint called.
A periodic mechanism (monthly/quarterly) is recommended to prevent:
- the potential leakage of a key,
- the persistence of old tokens signed with an obsolete key.
AI co-creation
Getting started
Explanations
How-To Guides
Reference
Digging deeper