Mert Özen Further With Every Line
Backend Node.js Fundamentals — #15

Security in Express: helmet, CORS, Rate Limiting, JWT Validation and Input Limits

Mert Özen Nov 14, 2026 14 min 14 views
Security in Express: helmet, CORS, Rate Limiting, JWT Validation and Input Limits

Securing an Express API means adding security headers with helmet, restricting CORS to an origin allow-list, applying a rate limit with trust proxy set correctly, validating JWTs with jose against a pinned algorithm, issuer, audience and lifetime, and checking ownership on every order access. With real output: a limit bypassed through X-Forwarded-For, a CORS setting that reflects every origin, 401 for alg none and algorithm confusion, 404 against BOLA, and prototype pollution.

Version note: The examples in this post were run with Node.js 26.13.1 (LTS), Express 5.3.0, helmet 8.3.0, cors 2.8.6, express-rate-limit 8.7.4, jose 6.2.17 and TypeScript 7.0.2.

Guess First

In Bookstore, the online bookstore used as the shared example across this site's series, we are adding rate limiting to the API: at most 3 requests per minute from the same IP address. Express's trust proxy setting decides where req.ip comes from. The lab below sends six requests from the same machine; each one claims a different client IP in the X-Forwarded-For header. There is no proxy in front of the API.

// Six requests, each claiming a different client IP in X-Forwarded-For; limit is 3 per window
import express from "express";
import { rateLimit } from "express-rate-limit";

for (const setting of [0, 1, true]) {
    const app = express();
    app.set("trust proxy", setting);
    app.use(rateLimit({ windowMs: 60_000, limit: 3, standardHeaders: "draft-8", legacyHeaders: false }));
    app.get("/", (req, res) => res.json({ ip: req.ip }));

    const server = app.listen(0, "127.0.0.1");
    await new Promise((resolve) => server.once("listening", resolve));
    const { port } = server.address() as { port: number };
    const statuses: number[] = [];
    for (let i = 1; i <= 6; i++) {
        const res = await fetch(`http://127.0.0.1:${port}/`, { headers: { "x-forwarded-for": `203.0.113.${i}` } });
        statuses.push(res.status);
    }
    console.log(`trust proxy = ${String(setting).padEnd(4)} -> ${statuses.join(" ")}`);
    server.close();
}

With which of the three settings does a 429 arrive from the fourth request on?

node labs/15/trust-proxy.ts
trust proxy = 0    -> 200 200 200 429 429 429
trust proxy = 1    -> 200 200 200 200 200 200
ValidationError: The Express 'trust proxy' setting is true, which allows anyone to trivially bypass IP-based rate limiting. See https://express-rate-limit.github.io/ERR_ERL_PERMISSIVE_TRUST_PROXY/ for more information.
trust proxy = true -> 200 200 200 200 200 200

The limit worked only with 0. 1 means "there is one proxy in front of me, trust the last value of X-Forwarded-For"; with no proxy there, the client wrote that value itself and every request looked like someone else. true trusts everything in the header; express-rate-limit catches this on the first request with the ERR_ERL_PERMISSIVE_TRUST_PROXY warning (I shortened its stack trace in the output) but does not block the request. The rule: trust proxy is the actual number of proxies in front of the API, no fewer and no more. In this post we harden the Bookstore API against the outside world: security headers, CORS, rate limiting, JWT validation, endpoint- and object-level authorization, and input limits. The concepts themselves live in the Web Security (OWASP Top 10) and Authentication and Authorization series; here we look at how they are applied in Express.

helmet and Security Headers

The first curl -i output in post 13 contained X-Powered-By: Express: a useless header that tells an attacker which framework is running. In post 11 we said we would turn it off. The start of createApp now looks like this:

const app = express();
// helmet removes the header too; this keeps it off even for responses that never reach helmet
app.disable("x-powered-by");
// req.ip comes from X-Forwarded-For only for this many proxy hops; 0 = the socket address
app.set("trust proxy", options.trustProxy);

// Global middleware runs for every request, in this order
app.use(requestContext);
// same-site instead of same-origin: the Angular client on another port of the same site can show covers
app.use(helmet({ crossOriginResourcePolicy: { policy: "same-site" } }));

With a single middleware, helmet adds a set of headers with their default values:

curl -s -o /dev/null -D - "http://127.0.0.1:3001/api/books?pageSize=1"
HTTP/1.1 200 OK
Content-Security-Policy: default-src 'self';base-uri 'self';font-src 'self' https: data:;form-action 'self';frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src 'self';script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Resource-Policy: same-site
Origin-Agent-Cluster: ?1
Referrer-Policy: no-referrer
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
X-DNS-Prefetch-Control: off
X-Download-Options: noopen
X-Frame-Options: SAMEORIGIN
X-Permitted-Cross-Domain-Policies: none
X-XSS-Protection: 0

For a JSON API the most important ones are X-Content-Type-Options: nosniff (the browser must not interpret JSON as HTML) and, behind HTTPS, Strict-Transport-Security. CSP really matters for HTML pages; on an API it does no harm. The one default we changed is Cross-Origin-Resource-Policy: helmet sets it to same-origin, which would stop the Angular application on port 4200 from showing the cover images we uploaded in post 08 with <img>. localhost:4200 and localhost:3000 are different origins but the same site, so same-site is enough. I did not try this in a browser; it is based on the helmet documentation and the header's definition. In ASP.NET Core these headers usually come from a middleware or a reverse proxy; there is no single standard package.

CORS: an Origin Allow-List

A browser lets JavaScript from another origin read the API only if the API allows it. The allow-list comes from configuration (CORS_ORIGINS=http://localhost:4200; zod validates that every value is a URL):

app.use(
    cors({
        origin: [...options.corsOrigins],
        methods: ["GET", "POST"],
        allowedHeaders: ["authorization", "content-type", "x-request-id"],
        exposedHeaders: ["location", "x-request-id"],
        maxAge: 600,
    }),
);

The preflight request Angular sends before a POST with an Authorization header, for an allowed origin and for a foreign one:

curl -s -o /dev/null -D - -X OPTIONS -H "origin: http://localhost:4200" -H "access-control-request-method: POST" -H "access-control-request-headers: authorization,content-type" http://127.0.0.1:3001/api/orders
curl -s -o /dev/null -D - -X OPTIONS -H "origin: https://evil.example" -H "access-control-request-method: POST" http://127.0.0.1:3001/api/orders
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:4200
Vary: Origin
Access-Control-Allow-Methods: GET,POST
Access-Control-Allow-Headers: authorization,content-type,x-request-id
Access-Control-Max-Age: 600

HTTP/1.1 204 No Content
Access-Control-Allow-Methods: GET,POST
Access-Control-Allow-Headers: authorization,content-type,x-request-id
Access-Control-Max-Age: 600

The second response has no Access-Control-Allow-Origin; the browser does not send the request. Remember that CORS is not a server-side security check: curl or a script never looks at these headers. CORS only protects the user in the browser against scripts from other sites. We added location to exposedHeaders, otherwise Angular cannot read the Location header of a 201 response. The classic trap is the "just make it work" setting:

// WRONG: origin: true reflects any Origin, and with credentials: true the browser sends cookies along
app.use(cors({ origin: true, credentials: true }));
access-control-allow-origin: https://evil.example
access-control-allow-credentials: true

The spec does not accept * together with credentials; origin: true gets around that rule by reflecting every origin one by one. If the API worked with cookies, any site could read the user's orders in a browser where they are signed in. Bookstore uses bearer tokens, so there is no need for credentials.

Rate Limiting and trust proxy

express-rate-limit limits every /api request to RATE_LIMIT_PER_MINUTE (120 by default) per IP per minute; to return the 429 as Problem Details like every other error, the limiter's handler passes an error to the error middleware:

app.use(
    "/api",
    rateLimit({
        windowMs: 60_000,
        limit: options.rateLimitPerMinute,
        standardHeaders: "draft-8",
        legacyHeaders: false,
        // 429 as Problem Details, like every other error
        handler: (req, res, next) => next(new HttpError(429, "too many requests, try again later")),
    }),
);

I lowered the limit to 5 and sent six requests:

200 200 200 200 200 429
HTTP/1.1 429 Too Many Requests
RateLimit: "5-in-1min"; r=0; t=60
Retry-After: 60
Content-Type: application/problem+json; charset=utf-8
{"type":"https://tools.ietf.org/html/rfc6585#section-4","title":"Too Many Requests","status":429,"detail":"too many requests, try again later","instance":"/api/books?pageSize=1","traceId":"req-15r"}

The RateLimit header is the newer format of the IETF's rate limit header draft: remaining requests (r) and seconds until the window resets (t). The default store lives in the process's memory; with four replicas as in post 14, each counts separately and the real limit is four times higher. With several instances, use a shared store such as Redis. A per-IP limit is a blunt tool: all users of an institutional customer may come from a single NAT IP. On authenticated endpoints, counting per user with keyGenerator is fairer. The ASP.NET Core counterpart is AddRateLimiter; the idea is the same.

We made trust proxy a number in configuration on purpose (TRUST_PROXY, 0–5) and do not allow true. Behind one load balancer it is 1, with direct connections 0. As we saw in Guess First, the wrong number misleads the rate limiter and the IPs in the logs at the same time.

JWT Validation: What AddJwtBearer Does

In ASP.NET Core AddJwtBearer is a few lines of setup; behind it are downloading the JWKS, verifying the signature, and checking the issuer, audience and lifetime. In Node we write these out explicitly with the jose package:

// What AddJwtBearer does for you in ASP.NET Core, spelled out: keys from JWKS, signature, alg, iss, aud, exp
export function createJwtVerifier({ issuer, audience, jwksUrl }: JwtOptions): TokenVerifier {
    // Keys are fetched on first use, cached, and fetched again when a token names an unknown kid
    const keys = createRemoteJWKSet(new URL(jwksUrl));
    return async (token) => {
        try {
            const { payload } = await jwtVerify(token, keys, {
                issuer,
                audience,
                // Pinned: a token cannot choose "none" or HS256 for itself
                algorithms: ["RS256"],
                requiredClaims: ["sub", "exp"],
                // ASP.NET Core allows 5 minutes of clock skew by default; jose allows none
                clockTolerance: 30,
            });
            return { userId: payload.sub!, roles: rolesOf(payload) };
        } catch (err) {
            throw new InvalidTokenError((err as { code?: string }).code ?? "ERR_UNKNOWN");
        }
    };
}

The middleware reads the Authorization: Bearer ... header, verifies the token and puts the result into res.locals.user. The reason for a rejection goes to the log; the client only learns that the token was not accepted:

export function authenticate(verify: TokenVerifier): RequestHandler {
    return async (req, res, next) => {
        const match = BEARER.exec(req.headers.authorization ?? "");
        if (!match) {
            res.setHeader("www-authenticate", "Bearer");
            throw new HttpError(401, "a bearer token is required");
        }
        try {
            res.locals.user = await verify(match[1]);
        } catch (err) {
            // The reason goes to the log; the client only learns that the token was not accepted
            logger.info({ reason: err instanceof InvalidTokenError ? err.code : (err as Error).message }, "token rejected");
            res.setHeader("www-authenticate", 'Bearer error="invalid_token"');
            throw new HttpError(401, "the access token is invalid or expired");
        }
        next();
    };
}

So that development does not need a real identity provider, we wrote scripts/dev-issuer.ts, the counterpart of dotnet user-jwts. On every start it generates a new RSA key pair in memory, publishes the public key at /.well-known/jwks.json and hands out tokens through POST /token. The key is never written to disk and never enters the repo; with NODE_ENV=production it refuses to start. The API connects to it through AUTH_ISSUER, AUTH_AUDIENCE and AUTH_JWKS_URL. labs/15/jwt-attacks.ts sends a series of requests with valid and forged tokens (I do not show the tokens themselves in the post):

npm run dev:issuer
node labs/15/jwt-attacks.ts
no token                                     401
Ada places an order                          201
Ada reads her order                          200
Alan reads Ada's order                       404
Grace (StoreStaff) reads Ada's order         200
Ada calls the export                         403
Ada sends customerId: alan in the body       400
expired two minutes ago                      401
token for another audience                   401
alg "none", no signature                     401
HS256 signed with the public key             401
RS256 signed with another key                401

The reasons for the last five lines are in the API log:

"reason":"ERR_JWT_EXPIRED"
"reason":"ERR_JWT_CLAIM_VALIDATION_FAILED"
"reason":"ERR_JOSE_ALG_NOT_ALLOWED"
"reason":"ERR_JOSE_ALG_NOT_ALLOWED"
"reason":"ERR_JWS_SIGNATURE_VERIFICATION_FAILED"

The token that expired two minutes ago was rejected because it exceeded the 30-second tolerance; the token issued for another API failed the aud check. alg: "none" and "algorithm confusion" sit in the same place. In algorithm confusion the attacker signs the token with HS256 and uses the API's RSA public key as the secret; HS256 is a symmetric algorithm built on HMAC, and the public key is in everyone's hands. The protection is the same for both: algorithms: ["RS256"] accepts no other algorithm, whatever the token's header says. Both attacks really worked against old libraries that read the algorithm from the token; that is why pinning the algorithm is a must. In the last line the attacker signed with their own key and wrote the real key's kid; the signature did not match. The response to the client is the same in every case:

HTTP/1.1 401 Unauthorized
x-request-id: req-15e
www-authenticate: Bearer error="invalid_token"
Content-Type: application/problem+json; charset=utf-8

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.2","title":"Unauthorized","status":401,"detail":"the access token is invalid or expired","instance":"/api/orders/ORD-1001","traceId":"req-15e"}

ASP.NET Core writes the reason in error_description by default in this situation; we kept the reason in the log, findable through the traceId. How the token is obtained, refresh tokens and OpenID Connect flows are the subject of the Authentication and Authorization series.

Authorization: BOLA and Role Checks

The middle of the lab output is about authorization. BOLA is the most common flaw in APIs: a user changes an ID and reaches someone else's record. GET /api/orders/:id now checks who owns the order:

// Object-level authorization: customers see their own orders, store staff see all of them
async getForUser(id: string, user: Principal): Promise<Order> {
    const order = await this.#orders.findById(id);
    // Someone else's order is "not found", not "forbidden": the answer must not confirm that it exists
    if (!order || (order.customerId !== user.userId && !user.roles.includes("StoreStaff"))) {
        throw new OrderNotFoundError(id);
    }
    return order;
}

Alan got 404 for Ada's order; had we returned 403, he could learn which order numbers exist by trying them. The check lives in the service because it is a business rule and will be tested without HTTP in post 16. The second change: the order's owner now comes from the token's sub claim instead of the body, and customerId was removed from the placeOrderBody schema. When Ada wrote "customerId": "alan" into the body she got 400 (strictObject, post 12). This is a breaking change for existing clients; the Angular client's request must drop the field. At the endpoint level, the export is open only to the StoreStaff role:

// Every order endpoint needs a signed-in user; the export is for store staff only
// "/export" before "/:id", so "export" is never taken for an id
export function ordersRouter(orders: OrderService, authenticated: RequestHandler): Router {
    return Router()
        .use(authenticated)
        .post("/", placeOrder(orders))
        .get("/export", requireRole("StoreStaff"), exportOrders(orders))
        .get("/:id", getOrder(orders));
}
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.4","title":"Forbidden","status":403,"detail":"this endpoint requires the StoreStaff role","instance":"/api/orders/export","traceId":"req-15f"}

The CPU-heavy price report from post 06 (/api/reports) is now closed to all but StoreStaff, and /diagnostics to all but Admin: letting an anonymous caller trigger a report that keeps the event loop busy was an easy DoS. The catalog (/api/books) stays public.

Input Limits and Prototype Pollution

We set most of the limits in earlier posts: a body of at most 64 KB (post 12; beyond it 413 and the connection is closed), a page size of at most 50, at most 50 order lines, length limits on ISBN and text fields, headersTimeout and requestTimeout on the HTTP server (post 10). There is one more Node-specific risk: prototype pollution. JSON.parse turns the "__proto__" key into an ordinary property; the danger starts when that object is copied into another one with a hand-written "deep merge" function:

// JSON.parse makes "__proto__" an ordinary own property; the danger starts when it is merged into another object
const body = JSON.parse('{"lines":[{"isbn":"9780441172719","quantity":1}],"__proto__":{"isStaff":true}}');

// The zod schema from post 12 rejects the unknown key before any code can merge it
const result = placeOrderBody.safeParse(body);
console.log("zod:", result.success ? "accepted" : result.error.issues.map((i) => i.message).join("; "));

// A naive recursive merge, the kind found in hand-written "deep merge" helpers
function merge(target: Record<string, any>, source: Record<string, any>): Record<string, any> {
    for (const key of Object.keys(source)) {
        if (typeof source[key] === "object" && source[key] !== null) {
            target[key] = merge(target[key] ?? {}, source[key]);
        } else {
            target[key] = source[key];
        }
    }
    return target;
}

merge({}, body);
const user = {};
console.log("after merge, any object is staff:", (user as { isStaff?: boolean }).isStaff);
node labs/15/prototype-pollution.ts
node --disable-proto=delete labs/15/prototype-pollution.ts
zod: Unrecognized key: "__proto__"
after merge, any object is staff: true
zod: Unrecognized key: "__proto__"
after merge, any object is staff: undefined

The merge wrote to Object.prototype through target["__proto__"], and now even an empty object in the process carries isStaff; if an authorization check looks at user.isStaff, everyone is staff. The defence has three layers: run the request through a schema first (in our API strictObject already rejects the key), do not write deep merges or skip the __proto__, constructor and prototype keys, and run the process with --disable-proto=delete. The last option removes the __proto__ accessor completely; the second run produced no pollution. It can break a dependency that relies on __proto__, so turn it on together with tests.

Mapping to the OWASP API Top 10, and Dependencies

The API Security (OWASP API Top 10) series covers each item in detail. What we did in this post, against the 2023 list:

OWASP API 2023In the Bookstore API
API1 Broken Object Level AuthorizationgetForUser: someone else's order is 404
API2 Broken AuthenticationSignature, alg, iss, aud and exp checks with jose
API3 Broken Object Property Level AuthorizationstrictObject, customerId from the token
API4 Unrestricted Resource ConsumptionRate limit, body and page size limits, timeouts
API5 Broken Function Level AuthorizationrequireRole: export, report, diagnostics
API8 Security Misconfigurationhelmet, CORS allow-list, X-Powered-By off, no details in error responses

On the dependency side, the npm audit we covered in post 04 belongs in CI. Scanning the production dependencies:

npm audit --omit=dev
found 0 vulnerabilities

Zero is today's result: every new package (we added four in this post) grows the attack surface, and a new vulnerability may appear tomorrow. The supply chain checklist is in post 04.

In Production

Set trust proxy to the real topology and, after deploying, check that the IPs in the logs really are client IPs.

Know what happens when the JWKS cannot be reached. createRemoteJWKSet caches the keys; if the identity provider is briefly unreachable, validation with known keys continues, but a request with a new kid fails.

Share the rate limit store. With several replicas, an in-memory counter multiplies the limit by the replica count.

Provide the CORS list per environment. localhost:4200 in development, only the real domain in production; origins added to the list "temporarily" tend to stay.

Count rejected tokens. A sudden rise in token rejected log lines can be a clock problem or someone trying tokens.

Common Pitfalls

trust proxy = true. Anyone can write whatever IP they like; the rate limit and IP-based rules lose their meaning.

cors({ origin: true, credentials: true }). Read access with the user's identity for every origin.

Not pinning the algorithm, and settling for jwt.decode. Decoding does not check the signature; validation always uses the expected algorithm, issuer and audience.

Forgetting the ownership check. A valid token must not make every order readable; the check belongs to every object access.

Taking identity from the body. Fields such as customerId, role or isStaff never come from the client; they come from the token or the database.

Logging the token. The Authorization header is on the redaction list (post 14); do not write the token to the log even while debugging.

A Small Experiment

★☆☆ Put * into CORS_ORIGINS and start the API. Which line stops the startup, with which message? Why is that the behaviour you want?

★★☆ Add a second rate limiter for the order endpoint: 10 orders per minute per user (the token's sub). Write the keyGenerator and mount the limiter after authenticate; explain why the order matters.

★★★ Restart dev-issuer and, without restarting the API, send a request with an old token and then with a new one. When does createRemoteJWKSet download the new kid? Why do the old and new keys need to sit side by side in the JWKS during key rotation?

The API now knows who may do what, but we tried all of it by hand. In the next post we turn the same scenarios into lasting tests: node:test, mocking, coverage and testing createApp with fake repositories through supertest, without opening a port.