How to secure a Node.js API before production

Tue Aug 18 2026

Your team task API has one browser dashboard, one Express backend, and PostgreSQL. Use a server-side session unless another client requires JWTs.

That decision is the first boundary. And you need controls for request shape, database access, browser origins, resource exhaustion, headers and deployment credentials. This guide walks those boundaries in that order and ends with a release gate you can run before production.

In this article

Map the five boundaries before adding middleware

Most Express security guides are middleware shopping lists. But each control belongs to a specific boundary and failure mode.

Express gives you routes and middleware composition. But you still have to define authentication, authorization, validation, rate limiting, CORS, security headers, SQL injection defenses and secrets handling yourself. The controls are sessions or JWTs for authentication, schema validation at the boundary, endpoint-specific rate limits, allowlisted CORS, Helmet headers, parameterized SQL, managed secrets, and a release checklist.

Think in five boundaries:

  • Identity: authentication identifies the caller; authorization checks whether that caller may perform this action.
  • Input: request bodies, query strings, and route parameters need shape and size limits.
  • Data: database values must stay separate from SQL syntax, and ownership must be checked.
  • Browser trust: CORS defines which browser origins may call credentialed routes.
  • Availability and deployment: callers must not consume unlimited resources, and production credentials must not leak.

These controls reinforce one another. Authentication won’t repair SQL injection. Validation won’t revoke a stolen token. CORS grants browsers permission; authentication protects the route. So keep them as separate checks.

Return generic errors to clients and keep diagnostic detail in protected logs. Attackers read error messages carefully. An endpoint that says whether an email exists already gives away useful information.

For a browser-only first-party app, choose sessions

Take the team task API. The browser dashboard talks to one server-side application, and POST /login establishes access to GET /tasks, POST /tasks, and PATCH /tasks/:id. Server-side sessions fit that shape because the application can invalidate the session when the user logs out or an administrator disables the account.

The sessions-first recommendation is strongest for one browser and one backend; your store, cookie policy, and token lifetime depend on your deployment and client mix.

ConcernServer-side sessionJWT
State locationServer-side session storeClient-held token
RevocationDelete or invalidate the sessionShort expiry plus a server-side revocation design
Browser exposureOpaque cookie with HttpOnlyCookie or another client storage mechanism
Multiple instancesRequires a shared session storeVerification can occur across instances
Best fitBrowser app with one server-side applicationMobile, service-to-service, IoT, or federated clients
Main burdenSession-store availabilityExpiry, refresh rotation, and logout design

An in-memory session store disappears on restart and can split state between production instances. Use a shared, durable session store when the application runs across instances.

Set cookies with HttpOnly, Secure, and SameSite=Lax or Strict when your application’s flows allow it. Rolling expiration can extend active sessions. Regenerate the session after login so an identifier issued before authentication cannot become the authenticated identifier:

req.session.regenerate((err) => {
  if (err) return next(err);

  req.session.userId = user.id;
  res.sendStatus(204);
});

HttpOnly prevents JavaScript from reading the cookie; it does not make an XSS vulnerability harmless. An injected script can still issue authenticated same-origin requests from the browser.

SameSite=None is required when a legitimate cross-site browser flow must send the cookie, and it requires Secure. In that architecture, state-changing cookie requests need an explicit CSRF defense. Lax or Strict is a useful choice when it matches your client topology, not a universal answer.

JWTs earn their complexity when credentials must travel to mobile clients, separate services, IoT devices or federated systems. Authentication identifies the caller; authorization middleware should then check ownership or role at the route boundary.

JWTs are useful, but only with an exit plan

JWT payloads are readable base64url data, not encrypted content, so keep passwords, secrets, and sensitive personal data out of them.

A verifier can accept a JWT without reading a session row. Logout then needs short expiry, a denylist, or another server-side control. For browser clients, storage affects the risk: an XSS payload can read a bearer token that JavaScript can access. An HttpOnly cookie limits token reading, while the XSS request risk still requires fixing the injection.

Use a JWT library that pins the accepted algorithm during verification and rejects none. Treat the signing algorithm and key distribution as deployment decisions. Before issuing long-lived tokens, document key rotation and how verifiers receive the replacement public key or secret.

The minimum refresh design looks like this:

login:
  issue a short-lived access JWT
  issue an opaque random refresh token
  store only its hash, user, device, and token family

refresh:
  hash the presented refresh token
  find its stored record
  if the token was already used:
    invalidate the entire token family
    reject the request
  invalidate the current token
  issue a new access token and refresh token

logout:
  revoke the refresh token and its device session

Use a random 256-bit refresh token, store only its hash, and rotate it after every exchange. A previously used token may signal theft. Invalidate the whole family and make the user authenticate again.

Short access-token lifetimes reduce exposure. Privileged routes should re-check roles or permissions server-side instead of trusting an old claim. “Stateless” describes verification state; it doesn’t remove the operational work of revocation.

Validate requests where they enter the system

Walk one task request through the boundary. POST /tasks should accept a title and optional ISO calendar date. PATCH /tasks/:id should accept only fields the route is designed to update.

A strict schema can enforce that contract:

import { z } from "zod";

const createTaskSchema = z.object({
  title: z.string().trim().min(1).max(200),
  dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()
}).strict();

const taskParamsSchema = z.object({
  id: z.coerce.number().int().positive()
});

const updateTaskSchema = z.object({
  title: z.string().trim().min(1).max(200).optional(),
  dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional()
}).strict();

function validateTaskRequest(req, res, next) {
  const body = createTaskSchema.safeParse(req.body);
  const params = taskParamsSchema.safeParse(req.params);

  if (!body.success || !params.success) {
    return res.status(400).json({ error: "Invalid request" });
  }

  req.validated = {
    body: body.data,
    params: params.data
  };

  next();
}

The example accepts YYYY-MM-DD; parse that value deliberately before storing it. Reject impossible dates such as 2026-02-31, and return one consistent validation error rather than exposing parser internals.

Validate body, params, query, and intended limits before business logic or database access. Pass the parsed values onward and discard the original unbounded object.

Validation answers, “Is this request shaped and bounded?” Output encoding handles the destination instead. Escape a task title for HTML, email, logs, or whichever destination consumes it. Cross-site scripting is almost always an output problem wearing an input costume.

This guide uses PostgreSQL, so MongoDB operator injection and parameter-pollution middleware are outside its scope. Add destination-specific defenses when your application actually handles those data flows.

Rate limits should follow the endpoint’s abuse case

A single global limiter is easy to install and easy to get wrong. Give each public surface a policy tied to the resource it protects:

SurfaceStarting policyWhy
POST /login5 attempts per 15 minutes per IP and accountSlows credential stuffing
POST /tasks30 requests per minute per authenticated accountLimits automated write abuse
GET /tasks120 requests per minute per authenticated accountLeaves room for dashboard refreshes
Public formsTune above normal user activityAvoids punishing legitimate submissions

Those numbers are starting points. Measure normal traffic, account for your reverse proxy and store counters somewhere shared when requests can reach multiple instances.

Use a maintained limiter implementation and apply limits to every public surface. Pair the login limit with a generic Invalid credentials response so the endpoint doesn’t reveal whether an account exists.

Application rate limits reduce the volume of automated guessing. They don’t absorb a network-scale denial-of-service attack. Put upstream protection in front of the API, and add body-size and slow-request limits so a client cannot hold connections open with tiny, delayed payloads.

This guide gives you the control pattern; your traffic and deployment model determine the exact windows, shared-store topology, and token lifetimes.

CORS should name the clients you trust

CORS grants browsers permission. Authentication protects the route. Configure them separately.

If the dashboard is hosted at https://app.example.com, allow that origin and enable credentials only for the route group that needs cookies:

import cors from "cors";

const allowedOrigins = new Set([
  "https://app.example.com"
]);

const browserCors = cors({
  origin(origin, callback) {
    // Decide explicitly whether requests without Origin should be accepted.
    if (origin === "https://app.example.com") {
      return callback(null, true);
    }

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

app.use("/api/browser", browserCors, browserApiRouter);

Never combine a wildcard origin with credentialed requests. Requests without an Origin header need an explicit policy decision. CORS does not authenticate them, and non-browser clients can ignore it entirely.

Helmet is a baseline, not a security review

Headers are useful. They’re also easy to mistake for a security review.

Register Helmet before your routes and inspect the resulting headers:

import helmet from "helmet";

app.use(helmet());

For an API that serves no HTML, Content Security Policy may have little direct value. Test HSTS only when every production access path is HTTPS and your proxy forwards the request scheme correctly.

Helmet helps withHelmet cannot fix
Several browser security headersMissing authorization
Framing and content-type protectionsUnsafe SQL
Baseline browser policyLeaked secrets or stolen refresh tokens

Check the version’s package documentation before relying on default behavior, and test headers with the actual frontend. A header baseline is worthwhile. Are the routes secure? Testing has to answer that.

Parameterized SQL is non-negotiable

For PostgreSQL values, placeholders keep the bound value separate from SQL syntax. They do not parameterize identifiers or sort expressions.

Use them for the task lookup:

const { id } = taskParamsSchema.parse(req.params);

const result = await pool.query(
  `SELECT id, title, due_date
   FROM tasks
   WHERE id = $1 AND owner_id = $2`,
  [id, req.user.id]
);

The owner_id predicate is the authorization check: it prevents one authenticated user from reading another user’s task. Parameterization and authorization solve different failures.

A sort expression changes query structure, so map a request value to source-controlled SQL:

const sortOrders = {
  newest: "created_at DESC",
  due: "due_date ASC"
};

const orderBy = sortOrders[req.query.sort] ?? sortOrders.newest;

const result = await pool.query(
  `SELECT id, title, due_date
   FROM tasks
   WHERE owner_id = $1
   ORDER BY ${orderBy}`,
  [req.user.id]
);

This remains safe only because every object value and the fallback are literals maintained in your source code. Never construct allowlist values from request input or database data.

String interpolation in SQL is a review failure, even when the value currently comes from a supposedly trusted internal variable. Query builders and ORMs still require review of the APIs and SQL they generate.

Treat environment variables as the floor

.env is a useful starting point. Production still needs a secrets-management strategy. A credential committed to Git and later deleted still needs rotation.

For local development:

  • Put .env in .gitignore before the first commit.
  • Commit .env.example with variable names and empty values.
  • Validate required variables at startup and fail loudly when one is missing.
  • Scan the current working tree and the repository history.

If your deployment runs a Node version that supports --env-file, it can load local environment files without an extra package:

node --env-file=.env server.js

Verify that command against the Node version you deploy.

For production, move upward through this maturity ladder:

  1. Remove hardcoded credentials.
  2. Use a gitignored local environment file.
  3. Load production values from a managed secret store.
  4. Inject them at runtime with least-privilege identities.
  5. Prefer short-lived credentials and OIDC where your platform supports them.

AWS Secrets Manager, Google Secret Manager, Azure Key Vault, and Vault are examples of managed stores. The important property is the operating model. The application receives only the credentials it needs, and those credentials can be rotated without rebuilding source code.

Scan the repository, Git history, container build context, and deployment configuration for secret-shaped values. A pre-commit check catches the next leak; history scanning catches the last one.

Run this checklist before you deploy

Treat these as release blockers. A forty-item document protects nothing if nobody runs it.

Identity

  • Protected routes use shared authentication middleware, followed by an ownership or role check.
  • Sessions set HttpOnly, Secure, and architecture-appropriate SameSite; login regenerates the session.
  • JWT verification pins algorithms and access tokens expire quickly.
  • Refresh tokens are opaque, hashed, rotated, and revoked on logout; reuse invalidates the token family.

Requests and data

  • Body, query, and route parameters are validated before business logic.
  • Body size, request time, and endpoint-specific rate limits are enforced.
  • Login returns a generic failure message.
  • Output is escaped for its destination.
  • SQL values use placeholders; structural fragments come only from source-controlled allowlists.
  • CORS names known origins, and credentialed responses never use *.
  • Helmet runs before routes, and its headers work with the production frontend.

Deployment

  • Missing required configuration stops startup.
  • Secrets are absent from the repository, Git history, build context, container image, and deployment configuration.
  • Exposed credentials have been rotated.
  • Production identities have only the permissions they need.
  • External errors are generic; detailed failures go to protected logs.
  • Deployed dependency versions and security settings have been reviewed against their current official documentation.

If one check fails, delay the deploy, name the owner, and rerun the gate after the fix. Security work becomes real when someone can block the release. Give that person a clear owner and a rerun date.