Repository navigation
Security
Summary: StartER implements essential protections to secure your web application against common vulnerabilities (CSRF, XSS, brute force attacks).
What you'll learn:
- How CSRF double-submit cookie protection works
- Identify the security headers set by Helmet
- Understand cookie security attributes and their purpose
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: false }));
app.use((req, res, next) => {
if (!isProduction) {
return next();
}
const nonce = crypto.randomBytes(16).toString("base64");
res.locals.cspNonce = nonce;
contentSecurityPolicy({
directives: {
scriptSrc: ["'self'", `'nonce-${nonce}'`],
},
})(req, res, next);
});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 uses Helmet's default values, with a per-request nonce added to script-src so that the scripts of the server-rendered page can run.
express-rate-limit protects against brute force attacks and abuse, in production only:
if (isProduction) {
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 pages and static assets (it is mounted after the API routes, so API responses are not compressed):
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".
Legitimate request (same origin)
Browser (React) Express Server
│ │
│ 1. Generate random token │
│ csrfToken() in mutate.ts │
│ │
│ 2. Store in cookie │
│ __Host-x-csrf-token=abc123 │
│ (SameSite=strict, 30s expiry) │
│ │
│ 3. Send mutation request │
│ POST /api/items │
│ Cookie: __Host-x-csrf-token=abc123 │
│ Header: x-csrf-token: abc123 │
│ Body: { title: "..." } │
│───────────────────────────────────────>│
│ │
│ 4. Compare: │
│ cookie === header ? │
│ abc123 === abc123 │
│ ✓ Match → proceed │
│ │
│ 201 Created │
│<───────────────────────────────────────│
Attacker request (cross-site)
Attacker Site (evil.com) Express Server
│ │
│ POST /api/items │
│ Header: x-csrf-token: guess123 │
│ (No cookie: SameSite=strict blocks it)│
│───────────────────────────────────────>│
│ │
│ cookie === header ? │
│ ∅ === guess123 │
│ ✗ No match │
│ │
│ 401 Unauthorized │
│<───────────────────────────────────────│
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.
Note
Why 401 and not 403? Using 401 makes CSRF failures indistinguishable from JWT authentication failures. If the server returned 403, an attacker would know their JWT is valid and only the CSRF token is the problem. A uniform 401 reveals nothing about what specifically failed. This is a deliberate security choice.
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) outside a web browser, you must provide matching CSRF token headers and cookies to avoid a 401 Unauthorized error. If the endpoint requires authentication, you must also pass the session cookie __Host-auth.
Let's detail below a 3-step raw RFC 7230 HTTP request sequence, compatible with the VS Code REST Client extension (.http files), Postman, Insomnia, Thunder Client, and standard curl.
You can create an api.http file in your editor workspace and execute this complete 3-step authentication and item creation sequence:
### ----------------------------------------------------------------------------
### Step 1: Request a Magic Link
### ----------------------------------------------------------------------------
POST http://localhost:5173/api/auth/magic-link
Content-Type: application/json
X-CSRF-Token: test-csrf-token
Cookie: __Host-x-csrf-token=test-csrf-token
{
"email": "dev@example.com"
}
# --- Expected Response ---
# HTTP/1.1 204 No Content
# Note: Check your terminal console for the generated verification token!
### ----------------------------------------------------------------------------
### Step 2: Verify Token & Receive Session Cookie
### ----------------------------------------------------------------------------
POST http://localhost:5173/api/auth/verify
Content-Type: application/json
X-CSRF-Token: test-csrf-token
Cookie: __Host-x-csrf-token=test-csrf-token
{
"token": "REPLACE_WITH_TOKEN_FROM_TERMINAL_CONSOLE"
}
# --- Expected Response ---
# HTTP/1.1 201 Created
# Set-Cookie: __Host-auth=eyJhbGci...; Path=/; Secure; HttpOnly; SameSite=Strict
# Content-Type: application/json
# { "id": 2, "email": "dev@example.com", "name": "dev", "avatar_url": null }
### ----------------------------------------------------------------------------
### Step 3: Create an Item (Authenticated + CSRF Protected)
### ----------------------------------------------------------------------------
POST http://localhost:5173/api/items
Content-Type: application/json
X-CSRF-Token: test-csrf-token
Cookie: __Host-x-csrf-token=test-csrf-token; __Host-auth=REPLACE_WITH_JWT_COOKIE_VALUE
{
"title": "My new item via HTTP Client"
}
# --- Expected Response ---
# HTTP/1.1 201 Created
# Content-Type: application/json
# { "insertId": 3 }For terminal users, here is the cURL equivalent for making a mutative POST request:
CSRF_TOKEN="test-csrf-token"
curl -X POST http://localhost:5173/api/items \
-H "Content-Type: application/json" \
-H "x-csrf-token: $CSRF_TOKEN" \
-b "__Host-x-csrf-token=$CSRF_TOKEN; __Host-auth=YOUR_JWT_COOKIE" \
-d '{"title": "My new item via cURL"}'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,
- only allow explicit sources for scripts and styles.
Tip
StartER starts from Helmet's default CSP and adds a nonce for scripts. 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. The token and its cookie both expire after 7 days, with a sliding window: each authenticated request issues a fresh token, so an active user stays logged in. This balances 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,
- There is no server-side revocation: logout deletes the cookie in the browser, but a copy of the token stays valid until it expires (7 days at most). To invalidate every token at once, change
APP_SECRET.
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.
StartER handles avatar uploads with a dedicated helper: files are validated (type, size), stored outside the document root, and served to authenticated users only. See File upload.
Any new upload must follow the same rules.
Helmet automatically configures essential security headers. Additionally, the production server (Caddy, Nginx, or other) can be configured to reinforce these headers:
-
Strict-Transport-Securityensures the browser communicates exclusively over HTTPS. -
X-Content-Type-Options: nosniffstops browsers from trying to guess MIME types. - Frame embedding is disabled via
X-Frame-Options: DENY. - Information sent via the
Refererheader is restricted usingReferrer-Policy: strict-origin-when-cross-origin.
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