Node.js 보안 필수 사항: 프로덕션에서 사용하는 체크리스트

작성자

카테고리:

← 피드로
DEV Community · Gulshan Yadav · 2026-09-05 개발(SW)

Every middleware, header, and validation rule that has actually stopped an attack — from a 7-year production record that includes one very expensive pentest.

The pentest report arrived on a Tuesday. Eleven findings, eight of them high severity. The client — an e-commerce backend serving about 40,000 requests a day — had everything a checklist of this kind is supposed to prevent: an open CORS policy, SQL injection on a search endpoint, a stack trace leaking database internals in a 500 response, and a session token being read from a log file. None of the attacks were exotic. Every single one was preventable with standard, boring middleware.

That week I wrote down every mitigation, in the order you should apply them, and turned it into a checklist. I have run that checklist on every Node.js service I have shipped since — APIs, webhooks, agent backends. This article is that checklist, with the code and the failure modes. Work through it top to bottom and you will have done more than most production systems on the internet.

Step 1: Get the Secrets Out of Your Code

The single highest-value fix is also the most boring: your code should contain zero secrets. No API keys, no database passwords, no JWT secrets, no connection strings. The environment is the only place secrets belong, and .env is gitignored before you write a single line.

# .gitignore
.env
.env.local
.env.production

Enter fullscreen mode Exit fullscreen mode

// config.js
const required = ["DATABASE_URL", "JWT_SECRET", "STRIPE_SECRET_KEY"];
for (const key of required) {
  if (!process.env[key]) {
    throw new Error(`Missing required environment variable: ${key}`);
  }
}
export const config = {
  databaseUrl: process.env.DATABASE_URL,
  jwtSecret: process.env.JWT_SECRET,
};

Enter fullscreen mode Exit fullscreen mode

Fail fast on startup if a required variable is missing — a service that refuses to boot beats one that boots with undefined as its JWT secret. And treat a leaked secret as a rotation event: the moment one hits git history, rotate it. Git history is permanent; the secret is not.

The pitfall I have seen every time: someone stores the secret in the repo “just for local dev” and forgets it. In 2026, most breaches do not start with a fancy exploit. They start with a .env file committed to a private repo that later goes public, or a secret pasted into a Slack thread that ends up in logs. Secrets are the attack surface nobody patches.

Step 2: Lock Down Your Dependencies

Node’s supply chain is its biggest risk. The average service pulls in hundreds of transitive packages, and one compromised dependency can undo every other control on this list. Two habits cover most of the ground.

First, run the audit on every install and on a schedule in CI:

npm audit --audit-level=high

Enter fullscreen mode Exit fullscreen mode

Second, commit your lockfile. package-lock.json should be in the repo and reviewed like code. If a transitive package changes hash without a deliberate upgrade, that is a red flag. And pin exact versions for production-critical packages instead of floating ranges — a ^ range today silently upgrades a minor version tomorrow, and you will not read the diff.

Set a cadence: a monthly npm audit fix review day, and zero tolerance for high or critical vulnerabilities that have a published fix. If you run the service in containers, add an image scanner (Trivy, Grype, or whatever your registry ships) to the pipeline. Dependency hygiene is security, full stop.

Step 3: Set the Security Headers

Headers are cheap insurance. helmet sets a dozen of them correctly in one call:

import express from "express";
import helmet from "helmet";

const app = express();
app.use(helmet());

Enter fullscreen mode Exit fullscreen mode

By default this sets Content-Security-Policy, X-Content-Type-Options: nosniff, X-Frame-Options, Referrer-Policy, and disables client-side caching of sensitive responses. For a JSON API, tighten the CSP further:

app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        frameAncestors: ["'self'"],
      },
    },
  })
);

Enter fullscreen mode Exit fullscreen mode

The pitfall: developers remove helmet because “it breaks my image embeds” and never re-add it. If CSP breaks a legitimate asset, tune the directive — do not delete the middleware.

Step 4: Configure CORS as a Deny-by-Default Policy

An open CORS policy — origin: "*" — is how the pentest found its client. Every browser-based attacker can then read your API’s responses. CORS must be an explicit allowlist:

import cors from "cors";

const allowedOrigins = [
  "https://www.example.com",
  "https://admin.example.com",
];

app.use(
  cors({
    origin(origin, callback) {
      if (!origin || allowedOrigins.includes(origin)) {
        return callback(null, true);
      }
      callback(new Error("Not allowed by CORS"));
    },
  })
);

Enter fullscreen mode Exit fullscreen mode

The !origin branch matters: server-to-server calls and curl have no Origin header and must still work. But never reflect the request origin back — that is the vulnerability. And never use * when credentials are in play; browsers ignore * with credentials anyway, so you end up with a broken, unsafe setup that gives a false sense of coverage.

Step 5: Validate Every Input, Always

Your API boundary is the trust boundary. Anything that arrives over the wire — query params, body, headers, cookies — is untrusted until proven otherwise. I use Zod for runtime validation and type inference together:

import { z } from "zod";

const createOrderSchema = z.object({
  amount: z.number().positive().max(100_000),
  currency: z.string().length(3),
  customerId: z.string().uuid(),
});

app.post("/api/orders", async (req, res) => {
  const parsed = createOrderSchema.safeParse(req.body);
  if (!parsed.success) {
    return res.status(400).json({ error: parsed.error.flatten() });
  }
  // parsed.data is fully typed and validated
});

Enter fullscreen mode Exit fullscreen mode

Validation stops the classic attacks in one move: SQL/NoSQL injection (no string concatenation into queries), prototype pollution (no raw object spread into config), and type confusion. Combine it with parameterised queries — never build SQL by string interpolation:

// Never this:
await db.query(`SELECT * FROM users WHERE email = '${email}'`);
// Always this:
await db.query("SELECT * FROM users WHERE email = $1", [email]);

Enter fullscreen mode Exit fullscreen mode

The pitfall: validation that only runs on “user input” and skips internal calls. An attacker does not care which layer you call input. Every path to the database validates its arguments.

Step 6: Rate Limit Everything User-Facing

Brute force, credential stuffing, scraping, and OTP bombing all run on the same weakness: no rate limit. You will find the full playbook in my rate limiting article, but the minimum is express-rate-limit on auth routes and a global limiter on the rest:

import { rateLimit } from "express-rate-limit";

const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  limit: 5,
  standardHeaders: "draft-8",
  message: { error: "Too many attempts. Try again later." },
});

app.use("/api/auth/login", authLimiter);

Enter fullscreen mode Exit fullscreen mode

Five attempts per fifteen minutes per IP is not user-hostile; it is what stops a credential-stuffing run in its tracks. For distributed limits across multiple instances, move the store to Redis — more on that in the dedicated article.

Step 7: Handle Sessions and Tokens Correctly

Auth is where most high-severity findings live. The boring, correct setup: short-lived access tokens, httpOnly cookies, and rotation on login.

res.cookie("session", jwt, {
  httpOnly: true,        // inaccessible to JavaScript — blocks XSS token theft
  secure: true,          // HTTPS only
  sameSite: "lax",       // blocks CSRF on cross-site requests
  maxAge: 15 * 60 * 1000,
  path: "/",
});

Enter fullscreen mode Exit fullscreen mode

If you must use Authorization: Bearer headers instead, accept the trade-offs consciously: tokens in headers are safe from cookie-CSRF but are commonly leaked into logs, proxies, and browser extensions. Whichever you choose, do three things without fail:

  1. Never store raw secrets in localStorage. One XSS and every token is gone.
  2. Verify on every request. Parse, check signature, check expiry, check revocation where the threat model demands it.
  3. Rotate on privilege changes. Password change, login from a new device, or a role upgrade should all issue a fresh token and invalidate the old session.

The pitfall: JWTs with month-long expiry “for convenience”. A stolen token is a permanent credential. Keep lifetimes short and make the refresh path do the work.

Step 8: Never Leak Internals in Errors

The client’s 500 response that printed a Postgres stack trace taught me this one. Production error responses should be indistinguishable from each other:

app.use((err, req, res, next) => {
  console.error(err); // full detail goes to your logs only
  res.status(500).json({ error: "Internal server error" });
});

Enter fullscreen mode Exit fullscreen mode

The full error — stack trace, query, file paths — goes to your logging pipeline. The client gets one opaque message. This single middleware closes a whole class of information-disclosure findings, and it costs nothing.

The pitfall that follows immediately: if you then log req.body verbatim, you have just written passwords, tokens, and payment details into your log files — the same logs you forward to third-party tooling, and the same logs that get leaked when anything else goes wrong. Before any logger runs, scrub the fields that should never be stored:

const SENSITIVE_FIELDS = ["password", "token", "authorization", "card_number", "cvv", "secret"];

function sanitize(obj) {
  const copy = { ...obj };
  for (const field of SENSITIVE_FIELDS) {
    if (field in copy) copy[field] = "[REDACTED]";
  }
  return copy;
}

// Use it everywhere before writing logs.
logger.info({ reqId, body: sanitize(req.body) });

Enter fullscreen mode Exit fullscreen mode

A breach that exposes a clean, redacted log is a reportable incident. A breach that exposes thousands of session tokens because they were sitting in logs is a catastrophe with a class-action attached. Sanitize before you store.

Step 9: Cap the Body, Mind the Payload

Unbounded request bodies are a memory-doS waiting to happen. Express accepts a body size limit in one line, and you should set it lower than you think you need:

app.use(express.json({ limit: "100kb" }));

Enter fullscreen mode Exit fullscreen mode

A multipart upload endpoint gets its own larger limit, and that is fine — the point is that the default “unlimited” is never acceptable in production. While you are at it, bound the things that indirectly scale with input: array lengths, pagination page sizes, and string lengths. I have seen a limit param of 999999999 take a database and its host down together. Validation limits are security controls, not UX preferences.

Step 10: Authorize Every Route, Not Just Authenticate

Authenticating tells you who the caller is. Authorization tells you what that caller is allowed to do — and the two get confused constantly. The classic finding: a route checks “is there a valid session?” and then lets any logged-in user fetch, update, or delete anyone else’s records. That is the broken-access-control class of vulnerability, and it is still one of the most common in the OWASP Top 10.

The fix is a per-route authorization check, layered after authentication:

function requireRole(...roles) {
  return (req, res, next) => {
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({ error: "Forbidden" });
    }
    next();
  };
}

function requireOwner(param) {
  return (req, res, next) => {
    const resource = req.params[param];
    if (resource.ownerId !== req.user.id && req.user.role !== "admin") {
      return res.status(403).json({ error: "Forbidden" });
    }
    next();
  };
}

app.get("/api/orders/:id", requireRole("customer", "admin"), requireOwner("id"), handler);

Enter fullscreen mode Exit fullscreen mode

The pitfall: authorization logic scattered across route handlers, so one route checks ownership and its neighbour does not. Centralize it in middleware, and write a test per protected route that asserts a 403 for the wrong user. “It was checked somewhere” is not a security control; “it is checked on every route, always” is.

Step 11: Run Least Privilege, Everywhere

The service itself should run as a non-root user with only the capabilities it needs. In a container:

FROM node:22-alpine
WORKDIR /app
COPY --chown=node:node . .
USER node
CMD ["node", "src/server.js"]

Enter fullscreen mode Exit fullscreen mode

And the database user your service connects with should have exactly the tables and operations it needs — never a superuser. If the app is compromised, the blast radius is a few tables, not the whole cluster. Least privilege is the difference between “breach” and “incident report”.

Step 12: Harden the Transport

HTTPS everywhere, terminated at your proxy, with HSTS telling browsers to never downgrade:

app.use(helmet({ hsts: { maxAge: 31536000, includeSubDomains: true } }));

Enter fullscreen mode Exit fullscreen mode

Redirect any HTTP request to HTTPS at the proxy layer, and make sure the API refuses to run in production over plain HTTP. TLS is table stakes in 2026; the browser’s lock icon is not a feature, it is the baseline.

The Production Checklist

Before I call a Node service done, it passes this list. Copy it.

  • [ ] Zero secrets in code — env only, .env gitignored, fail-fast on missing vars
  • [ ] npm audit clean (or a dated, reviewed exception) — run in CI
  • [ ] Lockfile committed, exact pins for critical deps
  • [ ] helmet active, CSP tuned to the actual assets
  • [ ] CORS allowlist, deny-by-default, never reflecting origin
  • [ ] Zod validation on every route; parameterised queries only
  • [ ] Rate limits on auth and public endpoints
  • [ ] httpOnly + secure + sameSite cookies; short token lifetimes; rotation on privilege change
  • [ ] Opaque production errors; full detail to logs only
  • [ ] Runs as non-root; DB user has least privilege
  • [ ] HTTPS + HSTS, HTTP redirect
  • [ ] Container image scanned in the pipeline

Run this checklist on your current service and you will be ahead of most teams — and several rungs above the service that produced that Tuesday report. Security in Node.js is not exotic. It is a list of boring, correct defaults, applied consistently and never switched off because they are inconvenient.

*Gulshan Yad

원문에서 계속 ↗