NestJS + Next.js Security Hardening: The Production Configuration
The concrete security configuration we ship to production for a NestJS backend and a Next.js frontend — helmet defaults, CSP, rate limits, HSTS, and the one authorization mistake we have seen three teams repeat.
Default security in NestJS and Next.js is thinner than most tutorials admit. Helmet ships with sensible defaults that are not enough for B2B buyers in 2026, the rate limiter needs Redis-backed storage to be honest in a multi-replica deploy, and the authorization guard pattern that everyone uses off the shelf has a subtle bug that three of our clients have shipped to production. This post is the configuration we ship.
Backend: NestJS hardening
Helmet, but not the defaults
The default helmet() call sets a small set of headers, but the defaults are not aggressive enough for a B2B audience. The configuration we ship:
- HSTS with
maxAge: 63072000(2 years),includeSubDomains: true,preload: true. Anything shorter is a signal to the buyer that you will rotate the cert and break their pinned client. - frameguard with
action: "deny"— strictlyDENY, notSAMEORIGIN. There is no UI in our app that another site needs to frame. - referrerPolicy with
policy: "strict-origin-when-cross-origin"— leak the bare origin on cross-origin navigation, nothing more. - Content-Security-Policy with a tight default-src 'self', then an explicit list of approved image / script / connect sources. The CSP is the single most important header for defense in depth; tune it before you ship, not after a security review.
Throttler, with Redis-backed storage
NestJS's @nestjs/throttler ships with in-memory storage by default. That is fine in a dev environment and unsafe in production: each replica has its own counter, and an attacker can send replicas × limit requests before any one of them trips the throttle.
The fix:
- Wire
@nest-lab/throttler-storage-redisso the counter is shared across replicas via Redis. - Set the storage URI to the same Redis as your cache. The auth throttler shares the bottleneck with the rest of the app, but that is correct — the auth path is where the abuse actually concentrates.
Brute-force protection, separately from rate limit
Rate limits answer "how often can a single IP try?" Brute-force protection answers "what happens when an account sees 5 wrong passwords?" Both are needed. The brute-force layer:
- 5 consecutive failures on the same account → 15-minute lock, returns 429 or 423 Locked.
- Counter resets on successful login.
- Inactive accounts are not locked. (The reason: an attacker should not be able to lock out an inactive account to prevent a legitimate admin from reactivating it.)
- Timing is equalized: bcrypt.compare runs against a dummy hash even when the email does not exist, so response time does not leak user enumeration.
Authorization guard mistake
The mistake we have seen three times in three different codebases:
// DANGEROUS — reads the role from a header that the client can set.
const role = request.headers["x-role"];
if (!requiredRoles.includes(role)) throw new ForbiddenException();
The fix:
// CORRECT — reads the role from the JWT that the server already verified.
const role = request.user.role;
if (!requiredRoles.includes(role)) throw new ForbiddenException();
The x-role header pattern bypasses the entire role system the moment a developer forgets to attach the JWT to a downstream request. It is a code path that compiles, deploys, and silently de-escalates. Centralize the role read on request.user (populated by the JWT strategy) and the bug category disappears.
Input validation
class-validator with the global ValidationPipe is the default. The non-default that matters:
whitelist: trueandforbidNonWhitelisted: true. If the client sends a field that is not in the DTO, reject the request. Defense against prototype pollution and accidental DTO drift.- A FeatureFlag on Joi / JSON Schema validation for endpoints where business rules depend on environment, not request shape.
Frontend: Next.js hardening
Headers via middleware
The Next.js middleware runs on every request before the page does. Use it for headers that must be set on the HTML response:
- Content-Security-Policy (echo the same policy the backend sets, with
frame-ancestors 'none'added). - Strict-Transport-Security (HSTS, matching the backend, with
max-age=63072000). - X-Content-Type-Options: nosniff.
- Referrer-Policy: strict-origin-when-cross-origin.
- Permissions-Policy: only the features we actually use (
camera=(self),microphone=(self)for Tolky; nothing else on the marketing surface).
Image optimization, remote patterns, and the next.config drift trap
images.remotePatterns is the allowlist for the next/image optimizer. The trap:
- A developer adds a new image domain, ships, and forgets.
- The next-intl plugin runs at runtime and replaces the
imagesblock — silently drops your custom remotePatterns. - Every image from that domain starts returning 400 from the optimizer.
The fix is a runtime guard that re-applies the config and a Dockerfile that explicitly COPY the next.config into the runner stage. The guard catches the drift in production; the Dockerfile copy catches it at build time. Two layers for the same reason: do not let the next-intl plugin eat your image optimization policy.
Rate-limit the auth endpoints from the frontend
If you can, rate-limit on the backend. If you cannot (e.g. a third-party auth provider), rate-limit on the frontend middleware to slow down credential-stuffing attempts to a tolerable rate. Two layers.
The shared mistake: log injection
The mistake we have seen across every NestJS + Next.js project we have audited: log messages that interpolate user input without escaping. A search field that logs Search query: ${req.query.q} becomes a log-injection vector the moment an attacker sends q=\n[FAKE LOG ENTRY]\n.
The fix is one line: escape any user input that lands in a log line. Almost every logging library has an escape or a structured-logging pattern that handles this for you. Use the pattern.
The closing principle
Security is not a feature. It is the absence of a class of bugs. The configuration above eliminates the classes we have seen shipped to production in the past year. The list will grow. Pin this post, review it quarterly, and ship a hardened baseline.
