Node.js API Security: How I Hardened My SaaS in a Weekend

Meta description: I secured my Node.js SaaS API from scratch — rate limiting, JWT rotation, input validation, and more. Here’s exactly how I did it, with real code and hard-won lessons.

Last updated: June 2026


A few months ago, I shipped a Node.js API for a small SaaS side project and immediately started seeing weird traffic in my logs — bots hammering /auth/login hundreds of times per minute. I hadn’t implemented rate limiting yet, and for a brief, sweaty moment I thought I was getting owned. Thankfully, no real damage was done. But that incident forced me to sit down and properly think through Node.js API security — not the tutorial-level stuff, but the decisions that actually matter when real users (and attackers) are hitting your endpoints.

This guide is what I wish I’d had before launching.


TL;DR

  • Rate limiting + Helmet.js covers 80% of the low-hanging-fruit attack surface with minimal effort.
  • JWT rotation and short-lived tokens prevent session hijacking in stateless APIs.
  • Input validation with Zod or Joi stops injection attacks and malformed payloads before they reach your business logic.

Why Node.js API Security Matters for Small SaaS

Small SaaS apps are not low-value targets — they often store payment info, user PII, and OAuth tokens. Attackers know indie developers tend to skip security hardening. A misconfigured Express app running on a VPS is a free lunch for anyone running automated scanners.

The good news: most common attack vectors against Node.js APIs are well-understood and have solid mitigation patterns. You don’t need a security team. You need the right libraries and the discipline to actually use them.

Security Note: OWASP’s API Security Top 10 is the best starting checklist for SaaS founders. Read it before you ship anything. [SOURCE: https://owasp.org/www-project-api-security/]


Prerequisites

Before diving in, you should be comfortable with:

  • Express.js (or Fastify — the patterns translate)
  • JWT-based authentication basics
  • npm package management
  • Basic understanding of HTTP headers and status codes

My stack for this guide: Node.js 20 LTS, Express 4.x, jsonwebtoken 9.x, Zod 3.x, and express-rate-limit 7.x.


Step-by-Step: Hardening Your Node.js API

Step 1: Lock Down HTTP Headers with Helmet

The first thing I do on every new Express project is install Helmet.js. It sets a collection of security-related HTTP headers in one line.

npm install helmet
const helmet = require('helmet');
app.use(helmet());

Out of the box, Helmet enables X-Content-Type-Options, X-Frame-Options, Strict-Transport-Security, and several others. The one gotcha I hit: Helmet’s default CSP policy broke my API’s Swagger UI. You may need to configure it manually:

app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'", "'unsafe-inline'"], // only if Swagger UI is needed
      },
    },
  })
);

[INTERNAL LINK: Express.js performance and configuration guide]


Step 2: Implement Rate Limiting

This is the fix I needed on day one. express-rate-limit is the standard choice for Express apps.

npm install express-rate-limit
const rateLimit = require('express-rate-limit');

const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 20, // max 20 requests per window per IP
  message: { error: 'Too many requests, please try again later.' },
  standardHeaders: true,
  legacyHeaders: false,
});

app.use('/auth', authLimiter);

I apply a stricter limit specifically to /auth routes. General API endpoints get a more generous limit (200 req/15min). The real-world trade-off: if your users are behind a corporate NAT, they may share an IP. In those cases, consider using authenticated user IDs as the rate limit key instead:

keyGenerator: (req) => req.user?.id || req.ip,

Step 3: Short-Lived JWTs with Refresh Token Rotation

The most common mistake I see in indie SaaS APIs is setting JWT expiry to 7d or 30d. If that token leaks — via a log, a client-side bug, a MITM — the attacker has a week of access.

My current pattern uses access tokens (15 minutes) + refresh tokens (7 days, stored in an HttpOnly cookie):

const jwt = require('jsonwebtoken');

function generateTokens(userId) {
  const accessToken = jwt.sign(
    { sub: userId, type: 'access' },
    process.env.JWT_ACCESS_SECRET,
    { expiresIn: '15m' }
  );

  const refreshToken = jwt.sign(
    { sub: userId, type: 'refresh' },
    process.env.JWT_REFRESH_SECRET,
    { expiresIn: '7d' }
  );

  return { accessToken, refreshToken };
}

The refresh token is sent as an HttpOnly, SameSite=Strict cookie — never in the response body. When the access token expires, the client hits /auth/refresh with the cookie. The server validates the refresh token, invalidates it in the database, and issues a new pair. This is refresh token rotation, and it’s critical.

Pro Tip: Store a hash of the refresh token in your DB, not the token itself. If your DB gets dumped, raw refresh tokens are useless to an attacker who doesn’t have the signing secret.

[SOURCE: https://datatracker.ietf.org/doc/html/rfc6749#section-10.4]


Step 4: Validate All Input with Zod

Input validation is your last line of defense against injection attacks and unexpected payloads that crash your app. I switched from Joi to Zod because it integrates naturally with TypeScript and has a cleaner API.

npm install zod
const { z } = require('zod');

const CreateUserSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8).max(128),
  plan: z.enum(['free', 'pro', 'enterprise']),
});

app.post('/users', async (req, res) => {
  const result = CreateUserSchema.safeParse(req.body);

  if (!result.success) {
    return res.status(400).json({ errors: result.error.flatten() });
  }

  const { email, password, plan } = result.data;
  // safe to use
});

The gotcha here: never trust Content-Type headers. Parse and validate before you do anything. I once had a staging API crash because a mobile client sent a number where a string was expected, and the downstream .toLowerCase() call threw an unhandled exception.


Step 5: Protect Against Mass Assignment

Mass assignment is when an attacker sends extra fields in a request body and you blindly pass req.body to your ORM. Example:

// DANGEROUS — never do this
await User.update({ id: req.user.id }, req.body);

If your user model has a role field, an attacker can just send { "role": "admin" } and elevate their privileges. Your Zod schema already solves this by explicitly defining allowed fields — only properties in the schema reach your business logic.


Real-World Tips I Use in Production

Environment variable validation at startup. I use Zod to validate process.env when the server boots:

const EnvSchema = z.object({
  JWT_ACCESS_SECRET: z.string().min(32),
  JWT_REFRESH_SECRET: z.string().min(32),
  DATABASE_URL: z.string().url(),
  NODE_ENV: z.enum(['development', 'production', 'test']),
});

const env = EnvSchema.parse(process.env);

This catches missing secrets before they cause runtime failures at 2am.

Structured logging with request IDs. I attach a requestId to every log entry using uuid and log at the error boundary. This makes it possible to trace a specific request through distributed logs.

Never expose stack traces in production. My global error handler strips the stack field in production responses:

app.use((err, req, res, next) => {
  const status = err.status || 500;
  res.status(status).json({
    error: err.message,
    ...(process.env.NODE_ENV !== 'production' && { stack: err.stack }),
  });
});

Common Errors and How I Fixed Them

Error: jwt malformed — This usually means the client is sending the raw token without the Bearer prefix, or there’s a whitespace issue. I log the raw Authorization header on the server side to debug this. Fixed by normalizing token extraction:

const token = req.headers.authorization?.replace(/^Bearer\s+/i, '');

Error: Cannot set headers after they are sent — Classic Node.js/Express issue when an async middleware calls next() after already sending a response. Fixed by adding return before every res.json() call in middleware.

Rate limiter not working behind a reverse proxy — When running behind Nginx or a load balancer, req.ip returns the proxy IP. Fix by setting:

app.set('trust proxy', 1);

This tells Express to trust the X-Forwarded-For header set by your proxy.


FAQ

Q: What is the best way to store JWT secrets in a Node.js SaaS application?
A: Use environment variables injected at runtime via a secrets manager (AWS Secrets Manager, Doppler, or Infisical). Never commit secrets to source control. Your secrets should be at least 32 random bytes long — I generate them with openssl rand -hex 32.

Q: How do I prevent brute force attacks on my Node.js login endpoint?
A: Combine rate limiting (per IP and per user ID), account lockout after N failed attempts, and CAPTCHA for repeated failures. Using bcrypt with a cost factor of 12 also slows down offline dictionary attacks if your DB is compromised.

Q: Should I use sessions or JWTs for a Node.js SaaS API?
A: For stateless, horizontally scalable APIs, JWTs with refresh token rotation are the better choice. If you need instant token revocation (e.g. for enterprise customers), maintain a token blocklist in Redis. Pure sessions are simpler but require sticky sessions or a shared session store.

Q: How does Helmet.js protect a Node.js Express application from common attacks?
A: Helmet sets HTTP headers that instruct browsers to refuse clickjacking attempts (X-Frame-Options), prevent MIME-type sniffing (X-Content-Type-Options), enforce HTTPS (HSTS), and more. It doesn’t protect against server-side logic flaws — it’s a browser-side hardening layer.

Q: What is the difference between input validation and input sanitization in Node.js APIs?
A: Validation rejects invalid input (wrong type, missing fields, out-of-range values). Sanitization modifies input to make it safe (stripping HTML tags, encoding special characters). You need both. Validate first with Zod, then sanitize before rendering output if you’re serving HTML.


Conclusion

Node.js API security for small SaaS apps doesn’t require a dedicated security engineer — it requires discipline and the right defaults. Start with Helmet and rate limiting on day one, implement JWT rotation before you go to production, and validate every payload with a schema. These four practices alone would have stopped every real incident I’ve encountered in my own projects.


About the Author: I’m a software engineer with 8 years of experience building backend systems and SaaS products in Node.js, TypeScript, and Go. I write about the practical side of software development — the stuff that only comes from actually shipping and breaking things in production. My current stack includes Node.js 20, PostgreSQL, Redis, and AWS.