Skip to content

Security

rocambille edited this page Nov 4, 2026 · 11 revisions

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

Security concepts

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.

Implementation in StartER

StartER integrates several security middlewares in server.ts, activated before any routes:

Helmet

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.

Rate limiting

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

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

Compression

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());
}

CSRF (Cross-Site Request Forgery)

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.

Client-side token generation

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                      │
     │<───────────────────────────────────────│

Server-side verification

The server:

  1. intercepts POST/PUT/PATCH/DELETE requests,
  2. reads the __Host-x-csrf-token cookie,
  3. compares it with the x-csrf-token header,
  4. responds with a 401 status 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=strict eliminates the possibility of a third-party site sending the necessary cookies.

Testing APIs outside the browser (VS Code REST Client, cURL, Postman)

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.

Full 3-step creation cycle in .http format

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 }
Equivalent curl command

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"}'

XSS protection

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 dangerouslySetInnerHTML anywhere.

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.

Additional tips

For projects that want to go further:

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.

Cookies, authentication, and integrity

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:

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.

Access tokens (JWT)

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.

Best practices and use cases

No cross-site cookies

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.

Stateless API

The absence of a server-side session limits the risks of:

  • session fixation,
  • incomplete cleanup,
  • memory overflow when tracking long sessions.

No uncontrolled direct uploads

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.

HTTP headers

Helmet automatically configures essential security headers. Additionally, the production server (Caddy, Nginx, or other) can be configured to reinforce these headers:

  • Strict-Transport-Security ensures the browser communicates exclusively over HTTPS.
  • X-Content-Type-Options: nosniff stops browsers from trying to guess MIME types.
  • Frame embedding is disabled via X-Frame-Options: DENY.
  • Information sent via the Referer header is restricted using Referrer-Policy: strict-origin-when-cross-origin.

Error handling and security messages

Errors returned by the backend are intentionally concise:

  • 400 for malformed requests,
  • 401 for authentication issues,
  • 403 for authorization issues,
  • 500 for 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.

Additional recommendations

Logging

Server-side logging is recommended for 401/403 errors, which include:

  • Authentication failures,
  • Unauthenticated access attempts,
  • Forbidden access.

Mutation monitoring

Mutative requests (PUT, POST, PATCH, DELETE) should be systematically logged with:

  • timestamp,

  • authenticated user,

  • endpoint called.

JWT key rotation

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.

See also

Clone this wiki locally