FullStack Course LogoFullStack Course
Module: Nodejs
Nodejs·127·19 MIN READ

127: Node/Express API Security — Validation, CORS, Cookies, Sessions, Headers, Rate Limits, and Abuse Resistance

TOPICS COVERED: Node/Express API Security — Validation, CORS, Cookies, Sessions, Headers, Rate Limits, and Abuse Resistance

Learning objectives

By the end of this lesson, you should be able to:

  • treat every value that crosses a network boundary as untrusted;
  • validate and normalize inputs before application code uses them;
  • explain what CORS does, and what it does not do;
  • configure and review security-related HTTP headers;
  • reason about cookie and session security;
  • distinguish authentication from authorization;
  • choose sensible rate limits and other abuse controls;
  • defend against body-size, regular-expression, query, and resource-exhaustion attacks;
  • configure proxy and client-IP trust deliberately;
  • recognize common injection and SSRF classes;
  • manage secrets without exposing them through code, logs, or URLs;
  • use a practical security checklist when reviewing a Node API.

Security is layered

An API is not secured by adding one package to an Express app. Security comes from several controls working together, with each layer limiting what can reach the next one:

text
network/TLS
↓
reverse proxy
↓
request limits
↓
authentication
↓
authorization
↓
validation
↓
business invariants
↓
database safe queries
↓
safe response
↓
logging/audit

This is a useful review model, not a claim that every request literally passes through these layers in exactly this order. For example, a proxy may enforce some limits before the Node process sees the request, while application code may authenticate before it validates resource-specific fields. The point is defense in depth: a failure or bypass in one control should not turn into unrestricted access.

No single middleware “secures Express.” Middleware can set headers or parse a body, but the application still has to make authorization decisions, enforce domain rules, use safe database queries, and operate within sensible resource limits.

Input sources

The first security boundary is the boundary between data received by the application and data the application is willing to trust. These sources are untrusted until the appropriate checks have happened:

text
req.params
req.query
req.body
req.headers
cookies
uploaded files
JWT claims unless cryptographically verified
webhook payloads
upstream API responses
database data originally supplied by users

That last category is easy to overlook. A database record may be inside your infrastructure, but if a user originally supplied its contents, it can still contain markup, unexpected values, or data intended to influence a later query or log entry. Validate at trust boundaries, including when data moves from one subsystem into another.

Schema validation

Checking fields one at a time in route handlers tends to produce inconsistent rules. A schema makes the accepted shape explicit and gives the route a parsed value to work with. For example, using a schema library such as Zod:

js
const CreateTaskSchema = z.object({
  title: z
    .string()
    .trim()
    .min(3)
    .max(80),

  priority: z
    .enum(['low', 'normal', 'high'])
    .default('normal'),
}).strict();

Here, title must be a string, surrounding whitespace is removed, and the resulting value must be between 3 and 80 characters. priority is limited to the three supported values and defaults to normal when it is absent. The exact rules belong to the domain, but the important property is that they are explicit.

Use strict parsing, or otherwise reject unknown fields, where accepting extra fields could be dangerous. An ignored field can become a mass-assignment problem if a later refactor starts copying it into a model.

Then parse the request at the route boundary:

js
const result = CreateTaskSchema.safeParse(req.body);

if (!result.success) {
  throw new ValidationError(...);
}

const input = result.data;

Do not pass the raw body deeper into the service or repository after validation. Use input, the value produced by the schema. That keeps later code from accidentally reintroducing fields or representations that the boundary rejected.

When debugging this boundary, inspect the validation result and the parsed output without logging secrets or an entire sensitive body. A 400 or equivalent validation response means that the request failed at the input boundary; it should not be confused with a database error or an authorization failure. Tests should cover a valid body, a missing field, a wrong type, an out-of-range value, and an unexpected field. Those cases show whether the schema enforces the contract rather than merely documenting it.

Type coercion

Query-string values arrive as strings or as parser-produced structures, even when the caller intended a number or Boolean. The distinction is not cosmetic:

text
"false" ≠ false
"0" ≠ 0

Schema coercion can be useful, but it needs tests around the edges. Check empty strings, repeated parameters, arrays, and malicious objects. A value such as ?active=false should not become truthy merely because the application checks whether the string exists. Decide what each representation means, then validate that decision explicitly.

Repeated parameters are especially worth inspecting in the route and in the validation result. Depending on the query parser, a repeated key may become an array instead of a single string. If the endpoint expects one value, reject that shape rather than letting a downstream library choose which element to use. This keeps the request contract stable when clients or attackers send representations that the normal UI never generates.

Prototype pollution

Some object-merging and parsing packages have historically had prototype-pollution vulnerabilities. Keep those dependencies patched, but do not rely on patching alone to define a safe data flow.

Do not deep-merge arbitrary request objects into configuration or domain objects. Prefer selecting the fields the endpoint actually supports and constructing the target object explicitly. This limits both known parser behavior and future mistakes in code that assumes an object contains only trusted properties.

NoSQL/operator injection preview

A query that looks convenient can turn user input into database instructions. The dangerous pattern is:

js
collection.find(req.query);

Depending on the database and parser, an attacker may supply operator-shaped values rather than ordinary filter values. Even if the current driver rejects some forms, passing the request object directly makes the security boundary unclear and fragile.

Never use a request object directly as a database filter. Build the filter from validated, allowed primitives, and define which operators the endpoint supports rather than inheriting the database's entire query language.

The same rule applies to sort fields, projection fields, and update objects. An allowlist for a filter is not automatically an allowlist for a sort expression or a field update. Keep each of those operations explicit so a caller cannot turn a harmless-looking query parameter into a database operation with a different cost or security effect.

SQL injection principle

This module may use Mongo later, but the general database rule applies across systems: use parameterized queries or ORM/query builders, and never concatenate untrusted strings into SQL. Validation helps define acceptable input; it does not replace parameterization, because a value that is valid for the domain can still contain characters meaningful to a query parser.

Command injection

Passing user input through a shell gives shell metacharacters a chance to become commands. This is dangerous:

js
exec(`convert ${req.body.filename} output.png`);

The safer shape is to invoke the executable without a shell and pass arguments separately:

js
spawn('convert', [
  validatedInputPath,
  outputPath,
]);

An argument array prevents the shell from interpreting the filename as shell syntax, but it does not make arbitrary input valid. Still validate paths and options, constrain where files may be read or written, and avoid invoking a shell unless the operation genuinely requires one.

Path traversal

Path traversal is covered in the filesystem lesson. The operational rule belongs here as well: never map a request path directly to a filesystem path. Resolve and constrain paths against an intended base directory, and validate identifiers rather than accepting arbitrary path syntax.

SSRF

SSRF is covered in the HTTP lesson. It occurs when a user-controlled outbound URL lets an attacker make the server request internal services or cloud metadata endpoints. The server's network position is different from the user's browser, so a URL that looks harmless from the client may reach highly privileged infrastructure.

Use a destination policy and egress controls. The policy should define permitted schemes, hosts, ports, and redirect behavior; network controls should provide a second barrier if application validation is bypassed.

CORS

When browser code calls an API on another origin, the browser may prevent that code from reading the response unless the API opts into the request through CORS. CORS controls whether browser JavaScript can read or call cross-origin resources under browser policy.

It does not authenticate requests. A request that passes CORS may still be made by an anonymous caller, and a request rejected by CORS may still reach the server.

A script running with curl or a server controlled by an attacker does not enforce browser CORS. Treat CORS as a browser access-control policy, not as an API firewall.

For an allowlist-based policy:

js
import cors from 'cors';

app.use(
  cors({
    origin(origin, callback) {
      if (!origin) {
        return callback(null, true);
      }

      if (allowedOrigins.has(origin)) {
        return callback(null, true);
      }

      callback(new Error('Origin not allowed'));
    },
    credentials: true,
  }),
);

The no-Origin case commonly covers non-browser clients, but it should be allowed because your API's client policy permits it, not because it is automatically trusted. When credentials are enabled, do not reflect an arbitrary Origin; return only origins that the application explicitly allows.

When investigating a CORS problem, inspect the browser Network panel and compare the preflight and actual response headers. A missing Access-Control-Allow-Origin is a browser-policy failure, not proof that the route returned an authorization denial. Conversely, seeing a CORS header does not prove that the route authenticated the caller. Keep the browser observation and the server's authorization decision as separate debugging facts.

Preflight

For some cross-origin requests, the browser first asks whether the actual request is allowed. That preflight request can include:

text
OPTIONS
Access-Control-Request-Method
Access-Control-Request-Headers

The framework's CORS middleware can handle this negotiation. A successful preflight only means the browser has permission to attempt the request. It says nothing about whether the caller is authenticated or authorized for the resource, so do not confuse preflight success with authorization.

Security headers

Helmet is a common Express package for setting security-related HTTP headers:

bash
npm install helmet
js
import helmet from 'helmet';

app.use(helmet());

Helmet provides useful defaults, but defaults are not a substitute for reviewing the policies your application actually needs. Consider at least:

  • Content Security Policy (CSP);
  • HTTP Strict Transport Security (HSTS);
  • frame protections;
  • referrer policy;
  • MIME-sniffing protections.

Do not disable protections globally to fix one frontend issue. Narrow the policy or fix the affected resource instead, because a broad relaxation can weaken every route.

CSP

Content Security Policy is especially relevant when Node serves browser pages. It controls which content and scripts the browser may execute and where some resources may load from.

JSON-only APIs still benefit from other security headers, but CSP primarily governs browser content execution. If React is hosted separately, configure CSP at the frontend delivery layer as well as any layer that serves browser documents. The correct policy depends on how scripts, styles, images, and connections are deployed; do not copy a policy without checking those dependencies.

Cookies

A session cookie commonly needs attributes like these:

text
HttpOnly
Secure
SameSite
Path
Domain carefully
Max-Age/Expires

HttpOnly reduces JavaScript access to the cookie, which limits one way browser scripts can steal it. Secure requires HTTPS, so the cookie is not sent over an unencrypted connection. SameSite reduces some cross-site request risks. Path, Domain, and the lifetime settings determine where and for how long the browser sends the cookie; set them as narrowly as the architecture allows.

The cookie name and value alone are not a security design. Review transport, scope, lifetime, fixation, CSRF, and session invalidation together.

Session IDs

A session cookie should normally contain an unpredictable session identifier, not raw sensitive session data. Raw data is only appropriate when it is protected by a carefully designed signed or encrypted format, with key management and validation handled correctly.

A server-side session store maps the identifier to the session data:

text
sessionId → user/session data

At login and privilege changes, session fixation prevention and session-ID rotation matter. If an attacker can cause a victim to authenticate into a session identifier the attacker already knows, authentication has not produced a safely separated session.

CSRF

Cookie-based authentication has a specific property: the browser may attach the cookie automatically to a request. Cross-Site Request Forgery (CSRF) abuses that behavior by tricking the browser into submitting an authenticated action from another site.

Mitigations depend on the architecture and should be evaluated together:

  • SameSite cookies;
  • CSRF tokens;
  • Origin/Referer validation;
  • custom headers plus CORS;
  • the framework's CSRF strategy.

Do not assume that using JSON automatically eliminates CSRF. The relevant question is whether an attacker-controlled origin can cause the browser to send a state-changing request with the user's credentials and have the server accept it.

For a threat model, enumerate state-changing methods and content types instead of considering only the login endpoint. Also check whether an existing endpoint accepts a simple form request, whether cookies are sent cross-site, and whether a state-changing action can be replayed. Test the mitigation with a request representing an untrusted origin, not only with a request from the application's own frontend.

Bearer tokens

An access token sent in an authorization header commonly has this form:

text
Authorization: Bearer <token>

“Bearer” means that whoever possesses the token can use it. Protect it in transit with TLS, choose storage carefully, keep it out of logs, and define how rotation and revocation work.

Do not put access tokens in URLs. URLs can leak through browser history, proxy logs, analytics, referrers, and other operational systems.

JWT misconception

JWT is a token format, not an authentication system by itself. A server still has to establish who issued the token and what the token is allowed to mean. That usually includes:

  • issuer;
  • audience;
  • signature verification;
  • an explicit algorithm policy;
  • expiration;
  • key rotation;
  • revocation or session policy;
  • authorization checks.

Authentication is covered in more depth in the authentication lesson. The practical boundary here is that decoding a JWT is not the same as verifying it, and verifying it is not the same as authorizing an operation.

Passwords

Never store plaintext passwords. Use a modern password-hashing function designed for passwords, such as Argon2id, bcrypt, or scrypt, according to current security guidance and the support of the ecosystem you are using.

Node's crypto module supports scrypt. Third-party password packages must be maintained and configured appropriately. Let the chosen algorithm or library generate and apply salts as designed; do not invent a separate salt or hashing format without a strong reason and security review.

This is not an adequate password-storage design by itself:

text
SHA256(password)

A fast general-purpose hash alone is designed to be fast to compute, which is useful for many integrity checks but makes large-scale password guessing more practical. Password hashing needs an algorithm and cost configuration intended for that threat.

Rate limiting

Rate limits control abuse and resource use. They are not a replacement for authentication or authorization, but they reduce how quickly an attacker can repeat expensive or sensitive actions.

Typical categories include:

text
login attempts
password reset
expensive search
public API
upload

A simple IP-only limit can punish many legitimate users behind NAT, and it is bypassable by a distributed attacker. When appropriate, combine dimensions such as:

  • account;
  • API key;
  • IP;
  • tenant;
  • endpoint cost.

The right combination depends on whether the request is authenticated, how expensive the endpoint is, and what abuse you are trying to contain. In a multi-instance deployment, an in-memory limiter on one Node process cannot enforce a consistent global limit. Use a shared store or an edge control when global consistency matters.

Measure the limit's behavior for both success and rejection. A normal response should not consume more work than the policy intends, and an exceeded limit should produce a predictable 429 without leaking account existence or internal limiter state. Also decide how trusted identity is selected: an IP-based dimension is only meaningful when the proxy configuration that supplies that IP is correct.

429

When a caller exceeds a limit, return:

text
429 Too Many Requests

You may also return Retry-After when the client can usefully determine when to try again. Avoid revealing excessive security detail, such as internal scoring rules or information that helps an attacker tune around the control.

Request size

Configure a body limit appropriate for the endpoint:

js
express.json({
  limit: '256kb',
});

Upload endpoints need separate limits because their legitimate payloads are different from ordinary JSON requests. Also consider limits at the reverse proxy. Defense in depth matters here: the proxy can reject an oversized request before it consumes application resources, while the application limit protects routes that are reached through another path.

Test the boundary through the actual deployment path. A request rejected by the proxy will not have the same logs or status-body shape as one rejected by Express, and that difference is useful when diagnosing where a limit lives. Apply the limit before code reads or buffers a large body, and make sure upload handling does not accidentally inherit the larger limit intended for another endpoint.

Timeout

Slow clients and slow upstreams can consume sockets, memory, and worker capacity. Review all relevant deadlines:

  • server header and request timeouts;
  • proxy timeouts;
  • upstream fetch deadlines;
  • database query limits.

Do not apply an arbitrary two-second timeout to every operation. Understand expected workloads, streaming behavior, and the failure semantics of each dependency, then set deadlines that bound resource use without routinely terminating valid work.

Regex DoS

An unsafe regular expression can exhibit catastrophic backtracking when it processes attacker-controlled input. Prefer safe patterns, bounded input lengths, and modern engines or features where appropriate.

Do not run complex regular expressions over megabytes of input. A regex that is harmless on a short username may become an availability problem when applied to an unbounded body or search field.

JSON/body CPU

Resource exhaustion can happen before business logic starts. Parsing very large JSON consumes memory and CPU, so body limits are both security controls and performance controls. Check the limit at the layer that first receives the request, and do not assume that rejecting the request later makes the earlier parsing cost disappear.

Pagination abuse

An otherwise valid query can still be operationally expensive. Reject or clamp requests such as:

text
limit=10000000

Enforce maximum filter sizes and date ranges as well. Pagination limits should reflect what the endpoint can serve predictably, not merely what the database technically accepts.

Database timeouts

Use database-driver timeouts or options such as maxTimeMS where appropriate. Do not allow a public endpoint to trigger an unbounded full-collection scan. Indexes and data modeling are availability concerns as well as performance concerns: a query shape that consumes the database can become a security issue under repeated traffic.

Trust proxy and IP

Express can be configured with a trust-proxy policy:

js
app.set('trust proxy', ...)

This setting must model the actual proxy hops in front of the application. If it is too permissive, an attacker may forge a forwarded IP address and bypass or distort:

  • secure-cookie detection;
  • rate limits;
  • IP-based access rules.

If it is too restrictive, the application may record or act on the wrong client address. Treat forwarded headers as trustworthy only after the request has crossed the proxies you explicitly trust.

When debugging an IP-related security issue, compare the socket peer, proxy-provided forwarded values, Express's resolved client address, and the address recorded by the limiter. Those values should match the deployment topology you intended. Do not solve a mismatch by blindly trusting every forwarded header; fix the proxy-hop model and test requests that include forged forwarding headers.

Logging security

Logs are data stores, not automatically safe diagnostic output. Never log:

text
password
Authorization bearer token
session cookie
credit-card full number
private key
full sensitive form body

Use redaction at the logging boundary and review structured fields as well as message text. Protect log retention and access because a properly secured application can still leak credentials through an over-permissive logging system.

Error security

Clients need a stable, non-sensitive error response for unexpected failures:

json
{
  "error": {
    "code": "INTERNAL_ERROR",
    "message": "The request could not be completed."
  }
}

Log the internal error details privately, with enough context to investigate but without secrets. Do not return Mongo connection strings, stack frames, filesystem paths, or other implementation details to the caller.

Dependency security

Dependency and runtime maintenance belong in the security process:

  • supported Node LTS;
  • supported Express;
  • lockfile;
  • npm audit/advisories;
  • dependency review;
  • minimal packages;
  • automated updates with tests.

Patch the runtime too, not only npm packages. A lockfile makes resolution reproducible, but it does not by itself make an old runtime or vulnerable dependency safe.

Dependency review should include transitive packages and the permissions of the process that runs the API. Keep production installs reproducible, remove packages that are not needed, and run updates through tests rather than applying them blindly during an incident. Advisories are a signal to investigate; the review still needs to determine whether the vulnerable code path is reachable and which remediation is appropriate.

TLS

Use HTTPS. If TLS terminates at a reverse proxy, secure the proxy-to-application network according to the threat model rather than assuming the internal network is harmless. In particular, do not accept plaintext secrets over a public network.

HTTPS protects the connection against network observers and tampering in transit, but it does not make a caller trustworthy and does not fix an authorization bug. Verify the certificate and termination configuration at the deployment boundary, then inspect the application behavior behind the proxy. A secure transport layer and safe server-side decisions are separate controls.

Authorization is server-side

A client may hide a Delete button for good user experience:

jsx
{canDelete && <button>Delete</button>}

That improves the interface, but it is not an access control. The server must still check every operation against the authenticated user, the tenant, the user's role or permissions, the resource ownership, and the resource status:

text
authenticated user
tenant
role/permissions
resource ownership
resource status

The useful distinction is authentication versus authorization: authentication identifies the caller; authorization decides whether that caller may perform this operation on this resource in its current state.

Multi-tenant query

Never trust a tenant identifier supplied in the body to scope the query:

json
{ "tenantId": "victim" }

Derive the tenant from the authenticated server context instead. A repository query should combine that trusted scope with the requested resource identifier:

js
repository.findOne({
  tenantId: auth.tenantId,
  id: taskId,
});

This prevents an IDOR/BOLA class of bug in which a caller changes an identifier and retrieves or modifies another tenant's resource. The authorization check must apply to reads, updates, deletes, and any related action, not just the initial list endpoint.

Test the route directly with a valid session that lacks the permission, with a session for another tenant, and with a resource in a state that disallows the operation. The route, service, and repository should preserve the trusted identity and scope rather than accepting a second copy of those values from the request body.

API security checklist

Before release, review the whole request path rather than checking only whether the application starts:

text
[ ] supported Node/Express
[ ] TLS
[ ] body/upload limits
[ ] schema validation
[ ] unknown fields policy
[ ] authentication
[ ] per-resource authorization
[ ] tenant scoping
[ ] CORS policy
[ ] CSRF policy
[ ] secure cookies/token storage
[ ] security headers
[ ] rate limits
[ ] upstream timeouts
[ ] database timeouts
[ ] query cost limits
[ ] path/SSRF/command injection review
[ ] secret redaction
[ ] safe errors
[ ] dependency/runtime patching
[ ] audit logs for sensitive actions

The checklist is a release prompt, not evidence that each control is correct. For every checked item, be able to point to the configuration, route behavior, test, or operational control that implements it.

Review the checklist against the deployed configuration as well as the source code. A correct local setting can be bypassed by a reverse proxy, environment variable, feature flag, or separate upload service. The review is complete only when the control is present at the boundary where the relevant input or resource can actually be abused.

Exercises

  1. Add strict schema validation to the Task API. Include the title and priority rules shown above, reject unknown fields, and make the route use the parsed result rather than req.body.
  2. Demonstrate that CORS does not block curl. Send the same endpoint request with curl and compare that result with a browser request from a disallowed origin.
  3. Configure Helmet and inspect the response headers. Identify which protections are present and which application-specific policies still need review.
  4. Design the attributes for a cookie-based session. Justify the choices for HttpOnly, Secure, SameSite, Path, Domain, and expiration.
  5. Threat-model CSRF for cookie authentication. Identify the state-changing requests, the browser behavior that makes them risky, and the mitigations your architecture will use.
  6. Add a shared-store rate-limit architecture design. Choose the dimensions and storage location for login, public API, and expensive-search limits, and explain how the design behaves across multiple instances.
  7. Build a safe outbound URL allowlist. Define permitted destinations and redirect behavior, then identify which egress controls provide defense in depth against SSRF.
  8. Fix the command-injection example with spawn arguments. Validate the input path, avoid the shell, and describe what filesystem constraints are still required.
  9. Design a tenant-scoped repository query. Derive the tenant from authenticated context and explain how the query prevents IDOR/BOLA.
  10. Write abuse test cases for body and page limits. Include oversized JSON, an oversized upload, an extreme page size, an excessive filter or date range, and the expected failure behavior.

Mastery checklist

You should be able to explain:

  • validation;
  • CORS;
  • CSP and other security headers;
  • sessions and cookies;
  • CSRF;
  • bearer-token and JWT limitations;
  • password hashing;
  • rate limits;
  • body and query limits;
  • trust proxy;
  • SSRF, path traversal, and command injection;
  • authorization and tenant scoping;
  • safe logging and errors.

If you can name a control but cannot describe what request it observes, what attack it limits, and what a misconfiguration looks like, revisit that section before treating the topic as mastered.

For a final review, trace one ordinary request and one hostile request through the layers in the diagram. Identify where each request is limited, which identity and tenant context are trusted, what reaches the database, what the client receives, and what is safe to record. This exercise connects the individual controls into an API security model you can apply to a new endpoint. The same trace also exposes controls that exist only in local development but are missing in deployment.

Official references

Reader page: /nodejs/lesson/127/node-express-api-security-validation-cors-cookies-sessions-headers-rate-limits-and-abuse-resistance