OWASP Hardening for Node.js APIs: NestJS, Express and Hapi.js

It started with a seemingly straightforward request: an enterprise client needed a security audit of their project before a major launch. During the review, a penetration tester found a replay attack vulnerability in a core payment flow in production. The scramble that followed was a massive wake-up call.
Most of us developers ship features, not security. By the time a data breach surfaces, the damage is already done. Over 50% of small to medium-scale projects ignore basic security guidelines, placing them directly on the radar of automated scanners and hackers. Here is how I hardened that real-world Nest.js backend across nine security layers.
The main examples use NestJS. If your API runs on plain Express or Hapi.js, the same protections for those frameworks are near the end, followed by a checklist that maps each layer to all three.
What is OWASP?
OWASP (Open Web Application Security Project) is a globally recognized nonprofit organization focused on improving the security of software applications, especially web apps and APIs. It is a community-driven initiative that provides security standards, open-source tools, and best-practice guides.
Let's break down the nine security layers you need, why they matter, and how to implement them.
Authentication And Authorization
If application endpoints lack protection, an attacker can directly call routes like /api/admin without any identity verification, and dump your entire user table. Broken access control is consistently at the top of the OWASP Top 10. Without strong authentication and Role-Based Access Control (RBAC), your application is vulnerable to horizontal privilege escalation, where one user accesses another user's restricted resources.
In Nest.js, this is handled cleanly using Guards. We implement an AuthGuard to verify JWT tokens and a RolesGuard to enforce access levels at the route or controller level.
import {
Injectable, CanActivate, ExecutionContext,
UnauthorizedException, ForbiddenException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private jwtService: JwtService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = request.headers.authorization?.split(' ')[1];
if (!token) throw new UnauthorizedException();
try {
request.user = await this.jwtService.verifyAsync(token);
return true;
} catch {
throw new UnauthorizedException('Invalid token');
}
}
}
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<string[]>('roles', [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles) return true;
const { user } = context.switchToHttp().getRequest();
if (!requiredRoles.includes(user?.role)) {
throw new ForbiddenException('Insufficient permissions');
}
return true;
}
}
AuthGuard validates the incoming bearer token and attaches the decoded user payload to the request. RolesGuard reads the required roles from a custom decorator and rejects callers who do not qualify.
Apart from this make sure the JWT token is encrypted you are generating or getting generated by OAuth (Google, Microsoft etc.). Otherwise, it can leak proprietary user information used to forge the token and validate the user.
Server-side authorization for every resource
Roles answer "can this kind of user call this route?". They do not answer "does this user own this record?". If GET /orders/:id only checks that the caller is a customer, any customer can read any order by changing the id. That is an insecure direct object reference (IDOR), and it is the most common form of broken access control I find in reviews.
The fix is to check ownership on the server, against the authenticated user, every time you load a record. Never trust a userId that comes from the request body or query string.
import { Injectable, NotFoundException } from '@nestjs/common';
@Injectable()
export class OrderService {
constructor(private readonly orders: OrderRepository) {}
async findForUser(orderId: string, user: { id: string; role: string }) {
const order = await this.orders.findOne({ where: { id: orderId } });
// Admins can see every order. Everyone else only sees their own.
if (!order || (user.role !== 'admin' && order.userId !== user.id)) {
// 404 instead of 403, so callers cannot probe which ids exist
throw new NotFoundException();
}
return order;
}
}
The controller passes request.user from the AuthGuard, never an id from the client. For list endpoints, put the owner in the query itself (where: { userId: user.id }) so a missing check cannot leak a whole table.
Input Validation And Sanitization
When raw payload data is passed directly into database queries or business logic, unexpected fields are silently accepted, causing crashes or data corruption. Injection flaws are consistently among the most common and dangerous vulnerabilities. Validating input at the boundary reduces the risk of malformed data, oversized payloads, and injection attempts reaching the application layer.
Nest.js provides a global ValidationPipe that pairs with class-validator and class-transformer. Setting whitelist: true silently strips extra properties, and forbidNonWhitelisted: true elevates that to an outright error, so attackers cannot smuggle extra fields through.
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { IsString, IsEmail, IsInt, IsOptional, MinLength } from 'class-validator';
export class CreateUserDto {
@IsString()
@MinLength(3)
username: string;
@IsEmail()
email: string;
@IsInt()
@IsOptional()
age?: number;
}
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe({
whitelist: true, // Strips unvalidated properties
forbidNonWhitelisted: true, // Throws on unknown properties
transform: true, // Auto-transforms to DTO instances
}));
await app.listen(3000);
}
bootstrap();
By pairing ValidationPipe with strongly typed DTOs, any unexpected field injected by a malicious user is immediately rejected before execution reaches the service layer.
API And Transport Security
An API without a CORS policy, without security headers, and leaking X-Powered-By: Express gives attackers a clear look at the stack. Basic transport security prevents abusive traffic and mitigates browser-based attacks like Cross-Site Scripting (XSS). Rate limiting makes brute-force credential stuffing mathematically impractical.
We use helmet for immediate HTTP header hardening inside main.ts and register @nestjs/throttler as a global guard to enforce request limits across the entire application.
import { Module } from '@nestjs/common';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { APP_GUARD } from '@nestjs/core';
import { NestFactory } from '@nestjs/core';
import helmet from 'helmet';
@Module({
imports: [
ThrottlerModule.forRoot([{
ttl: 60000, // 1 minute window
limit: 100, // max requests per window per IP
}]),
],
providers: [
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
],
})
export class AppModule {}
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.use(helmet()); // Drops X-Powered-By, sets HSTS, CSP, and more
app.enableCors({
origin: ['https://mydomain.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
});
await app.listen(3000);
}
bootstrap();
helmet() automatically drops the X-Powered-By header and enforces strict transport security policies. ThrottlerGuard applies globally, so every route is rate-limited without any per-controller setup.
While this strategy secures your code, you might need to verify that your nginx or apache server also block this kind of headers. If you run the API in containers, I covered the build and deploy side in Deploy a Node.js Application Using Docker. Check the headers on the URL users actually hit, not just the Node process.
Request Integrity And Replay Protection
An attacker can intercept an HTTP request, such as a payment transfer, and replay it multiple times. Standard authentication proves who sent the request, but not when it was sent or whether it has already been processed. Replay protection is therefore essential for any state-changing endpoint.
We handle this in Nest.js with a dedicated ReplayProtectionGuard backed by the @nestjs/cache-manager module. Each request must carry a unique nonce (number used once) and a timestamp header. The guard rejects requests older than five minutes and flags any nonce it has already seen using Redis.
import {
Injectable, CanActivate, ExecutionContext,
BadRequestException, ConflictException, Inject,
} from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
@Injectable()
export class ReplayProtectionGuard implements CanActivate {
constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const nonce = request.headers['x-request-nonce'];
const timestamp = parseInt(request.headers['x-request-timestamp'], 10);
if (!nonce || !timestamp) {
throw new BadRequestException('Missing integrity headers');
}
if (Date.now() - timestamp > 5 * 60 * 1000) {
throw new BadRequestException('Request expired');
}
const seen = await this.cacheManager.get(`nonce:${nonce}`);
if (seen) {
throw new ConflictException('Duplicate request detected');
}
await this.cacheManager.set(`nonce:${nonce}`, '1', 300000); // 5 min TTL
return true;
}
}
Request signing: why a nonce alone is not enough
The guard above has two gaps. First, nothing ties the nonce and timestamp to the request. An attacker who captured the request can send it again with a fresh nonce and the current time, and the guard accepts it. Second, the get and set are two calls, so two copies of the same request sent at the same moment can both pass the check.
Request signing closes the first gap. The client computes an HMAC over the method, path, timestamp, nonce and a hash of the body with a secret shared with the server, and sends it in an x-request-signature header. If any of those values change, the signature no longer matches. Storing the nonce with Redis SET ... NX closes the second gap, because only one request can create the key.
import {
Injectable, CanActivate, ExecutionContext, UnauthorizedException,
} from '@nestjs/common';
import { createHash, createHmac, timingSafeEqual } from 'crypto';
import Redis from 'ioredis';
const MAX_AGE_MS = 5 * 60 * 1000;
@Injectable()
export class SignedRequestGuard implements CanActivate {
constructor(
private readonly redis: Redis,
private readonly keys: ClientKeyService, // looks up the secret for a client id
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest();
const clientId = req.headers['x-client-id'];
const nonce = req.headers['x-request-nonce'];
const timestamp = Number(req.headers['x-request-timestamp']);
const signature = String(req.headers['x-request-signature'] ?? '');
if (!clientId || !nonce || !timestamp || !signature) {
throw new UnauthorizedException('Missing signature headers');
}
if (Math.abs(Date.now() - timestamp) > MAX_AGE_MS) {
throw new UnauthorizedException('Request expired');
}
const secret = await this.keys.secretFor(clientId);
if (!secret) throw new UnauthorizedException();
// req.rawBody needs NestFactory.create(AppModule, { rawBody: true })
const bodyHash = createHash('sha256').update(req.rawBody ?? '').digest('hex');
const payload = [req.method, req.originalUrl, timestamp, nonce, bodyHash].join('\n');
const expected = createHmac('sha256', secret).update(payload).digest('hex');
const a = Buffer.from(signature, 'hex');
const b = Buffer.from(expected, 'hex');
if (a.length !== b.length || !timingSafeEqual(a, b)) {
throw new UnauthorizedException('Invalid signature');
}
// Only the first request with this nonce can create the key
const stored = await this.redis.set(`nonce:${clientId}:${nonce}`, '1', 'PX', MAX_AGE_MS, 'NX');
if (stored !== 'OK') throw new UnauthorizedException('Duplicate request');
return true;
}
}
The nonce lives in Redis for the same five minutes the timestamp is valid, so the store never grows without bound. Use timingSafeEqual instead of === so the comparison time does not reveal how much of the signature was right.
Signing works best between services or for clients that can keep a secret, such as a mobile app with a key issued at login. For a payment flow from a browser, pair it with an idempotency key on the server: the client sends one key per payment, and the server returns the stored result if it sees the same key again instead of charging twice.
Sensitive Data Handling
At some point, you query a user record and return the raw database object. The response now contains passwordHash, twoFactorSecret, and internal tokens that were never meant to leave the server. A single leaked hash or credential is often enough for a full account takeover.
Rather than manually scrubbing fields, Nest.js can do this automatically via ClassSerializerInterceptor. Decorating entity fields with @Exclude() ensures those properties are stripped from every response that flows through the interceptor, without touching controller logic.
import {
UseInterceptors, ClassSerializerInterceptor,
Controller, Get,
} from '@nestjs/common';
import { Exclude } from 'class-transformer';
export class UserEntity {
id: number;
email: string;
@Exclude()
passwordHash: string;
@Exclude()
twoFactorSecret: string;
constructor(partial: Partial<UserEntity>) {
Object.assign(this, partial);
}
}
@Controller('users')
@UseInterceptors(ClassSerializerInterceptor)
export class UserController {
@Get('profile')
getProfile(): UserEntity {
const raw = {
id: 1,
email: 'user@example.com',
passwordHash: 'bcrypt_hash_here',
twoFactorSecret: 'totp_secret_here',
};
// The interceptor automatically removes @Exclude() fields before sending
return new UserEntity(raw);
}
}
File Upload Security
You allow users to upload profile pictures. An attacker uploads a PHP script disguised as image.jpg. When the server processes or stores that file in a shared bucket, the entire system is at risk. Raw file uploads are one of the most direct paths to remote code execution (RCE) or malware distribution across multi-user systems.
Nest.js wraps multer through FileInterceptor, and ParseFilePipe adds a validation layer on top. The FileTypeValidator checks the actual MIME type reported by the upload, and MaxFileSizeValidator enforces a hard size cap before any storage logic runs.
import {
Controller, Post, UseInterceptors,
UploadedFile, ParseFilePipe,
MaxFileSizeValidator, FileTypeValidator,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
@Controller('avatar')
export class AvatarController {
@Post('upload')
@UseInterceptors(FileInterceptor('avatar'))
uploadFile(
@UploadedFile(
new ParseFilePipe({
validators: [
new MaxFileSizeValidator({ maxSize: 5 * 1024 * 1024 }), // 5 MB
new FileTypeValidator({ fileType: '.(png|jpeg|jpg|webp)' }),
],
}),
)
file: Express.Multer.File,
) {
return { message: 'Upload successful', filename: file.originalname };
}
}
Apart from this basic file validation, you should be check file for malicious code or structure. We used ClamAV to check the malicious files before actually uploading it to our storage server (s3 or blob-storage). It is an open-source antivirus engine for detecting trojans, viruses, malware & other malicious threats.
External Communication Security
Your application accepts a user-supplied URL to fetch a webhook or generate a preview. Instead of a legitimate domain, the attacker provides http://169.254.169.254, the AWS instance metadata endpoint, which returns live IAM credentials. Server-Side Request Forgery (SSRF) lets attackers use your server as a proxy into private networks, internal services, or cloud provider metadata APIs.
The defense is to validate every outbound URL before the HTTP call is made. We do this in a dedicated Nest.js service using @nestjs/axios, resolving the hostname to an IP address first and then blocking any address that falls in a private or loopback range.
import { Injectable, BadRequestException } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { firstValueFrom } from 'rxjs';
import * as isIpPrivate from 'is-ip-private';
import { promises as dns } from 'dns';
@Injectable()
export class SafeHttpService {
constructor(private readonly httpService: HttpService) {}
async fetchExternalResource(userUrl: string) {
let parsedUrl: URL;
try {
parsedUrl = new URL(userUrl);
} catch {
throw new BadRequestException('Invalid URL format');
}
const { address } = await dns.lookup(parsedUrl.hostname);
if (isIpPrivate(address)) {
throw new BadRequestException('Request to private network blocked');
}
const response = await firstValueFrom(
this.httpService.get(userUrl, { timeout: 3000 }),
);
return response.data;
}
}
Database Safety
When a user's API request can dictate the query filter directly, an attacker can pass a MongoDB operator like { "email": { "$ne": null } } to bypass authentication and dump every record in the collection. NoSQL and SQL injections bypass access controls entirely and can expose data that belongs to every user of the system.
The safest pattern in Nest.js is to use a typed ORM such as TypeORM or Prisma, which parameterizes queries automatically. Combined with the ValidationPipe from earlier, the shape of incoming data is strictly controlled before it ever reaches the service layer.
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserEntity } from './user.entity';
@Injectable()
export class UserService {
constructor(
@InjectRepository(UserEntity)
private userRepository: Repository<UserEntity>,
) {}
async findUser(email: string): Promise<UserEntity | null> {
// TypeORM parameterizes the query, neutralizing injection attempts.
// The email value is already validated as a string by ValidationPipe.
return this.userRepository.findOne({
where: {
email,
isActive: true,
},
});
}
}
The same rule applies when the query is not written by a developer at all. When I built natural language to SQL with MCP, the server only ran queries that start with SELECT. A string check like that is a first filter, not a guarantee. In production, also run model-written SQL as a database user that can only read, because you cannot validate a query you have not seen.
Notification And Real-Time Security
WebSocket connections that accept any client without verifying identity let an attacker silently subscribe to the event stream of any user on the platform. Real-time channels carry sensitive payloads, such as billing events, approval notifications, and live data updates, that are hard to audit after a breach.
Nest.js WebSocket Gateways support Guards through the same CanActivate interface used by HTTP routes. We create a WsJwtGuard that reads the token from the socket handshake and runs the same JWT verification used for the REST layer.
import {
UseGuards, Injectable, CanActivate, ExecutionContext,
} from '@nestjs/common';
import { WebSocketGateway, WebSocketServer, SubscribeMessage } from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { JwtService } from '@nestjs/jwt';
@Injectable()
export class WsJwtGuard implements CanActivate {
constructor(private jwtService: JwtService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const client: Socket = context.switchToWs().getClient();
const token = client.handshake.auth.token;
if (!token) return false;
try {
client.data.user = await this.jwtService.verifyAsync(token);
return true;
} catch {
return false;
}
}
}
@WebSocketGateway()
export class EventsGateway {
@WebSocketServer()
server: Server;
@UseGuards(WsJwtGuard)
@SubscribeMessage('secureEvent')
handleMessage(client: Socket, payload: unknown): void {
// Only authenticated clients reach this handler
client.join(`user-room-${client.data.user.id}`);
}
}
OWASP Protection For Express
Express gives you less out of the box than NestJS, so each layer is a middleware you add yourself. The order matters: headers and body limits first, then rate limiting, then authentication, then validation inside the route.
import express from 'express';
import helmet from 'helmet';
import { rateLimit } from 'express-rate-limit';
import jwt from 'jsonwebtoken';
import { z } from 'zod';
const app = express();
app.use(helmet()); // security headers, removes X-Powered-By
app.use(express.json({ limit: '100kb' })); // reject oversized bodies
app.use(rateLimit({ windowMs: 60_000, limit: 100, standardHeaders: 'draft-8', legacyHeaders: false }));
function requireAuth(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.sendStatus(401);
try {
// Pin the algorithm so a token signed with "none" or another key type is rejected
req.user = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
next();
} catch {
res.sendStatus(401);
}
}
const requireRole = (...roles) => (req, res, next) =>
roles.includes(req.user?.role) ? next() : res.sendStatus(403);
// .strict() rejects unknown fields, like forbidNonWhitelisted in NestJS
const CreateUser = z.object({
username: z.string().min(3),
email: z.string().email(),
age: z.number().int().optional(),
}).strict();
app.post('/users', requireAuth, requireRole('admin'), async (req, res) => {
const parsed = CreateUser.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ errors: parsed.error.issues });
const user = await users.create(parsed.data);
res.status(201).json({ id: user.id, email: user.email }); // never return the raw record
});
// Server-side authorization: the owner comes from the token, not the request
app.get('/orders/:id', requireAuth, async (req, res) => {
const order = await orders.findOne({ id: req.params.id, userId: req.user.id });
if (!order) return res.sendStatus(404);
res.json(order);
});
Express 5 forwards errors from async handlers to the error middleware. On Express 4, wrap async handlers or an unhandled rejection can leave the request hanging. The replay protection and request signing from earlier work the same way as Express middleware: read the headers, verify the HMAC over the raw body (express.raw() or the verify option of express.json()), and store the nonce with SET NX.
OWASP Protection For Hapi.js
Hapi.js has more security built in than Express. It does not send X-Powered-By, Joi validation rejects unknown keys by default, and payloads are capped at 1 MB unless you change it. You still have to turn on the security headers, authentication and rate limiting.
import Hapi from '@hapi/hapi';
import Jwt from '@hapi/jwt';
import RateLimit from 'hapi-rate-limit';
import Joi from 'joi';
const server = Hapi.server({
port: 3000,
routes: {
security: true, // HSTS, X-Frame-Options, nosniff and more
cors: { origin: ['https://mydomain.com'] },
payload: { maxBytes: 100 * 1024 },
},
});
await server.register(Jwt);
await server.register({
plugin: RateLimit,
options: { userLimit: 100, userCache: { expiresIn: 60_000 } },
});
server.auth.strategy('jwt', 'jwt', {
keys: process.env.JWT_SECRET,
verify: { aud: false, iss: false, sub: false, maxAgeSec: 15 * 60 },
validate: (artifacts) => ({
isValid: true,
credentials: { user: artifacts.decoded.payload, scope: artifacts.decoded.payload.role },
}),
});
server.auth.default('jwt'); // every route needs a valid token unless it opts out
server.route({
method: 'POST',
path: '/users',
options: {
auth: { scope: ['admin'] }, // role check
validate: {
payload: Joi.object({
username: Joi.string().min(3).required(),
email: Joi.string().email().required(),
age: Joi.number().integer(),
}),
},
},
handler: async (request, h) => {
const user = await users.create(request.payload);
return h.response({ id: user.id, email: user.email }).code(201);
},
});
server.route({
method: 'GET',
path: '/orders/{id}',
handler: async (request, h) => {
const { user } = request.auth.credentials;
const order = await orders.findOne({ id: request.params.id, userId: user.id });
return order ?? h.response().code(404);
},
});
Setting server.auth.default('jwt') is the most useful line here. New routes are protected unless someone writes auth: false on purpose, which is much easier to catch in review than a forgotten guard. For replay protection, put the signature and nonce check in an onPreAuth extension or its own auth scheme so it runs before any handler.
OWASP Checklist For Node.js APIs
- Authentication: NestJS
AuthGuardwith@nestjs/jwt. Expressjsonwebtokenmiddleware with a pinned algorithm. Hapi.js@hapi/jwtwithserver.auth.default. - Role and ownership checks: NestJS
RolesGuardplus an owner check in the service. ExpressrequireRoleplus the owner in the query. Hapi.jsauth.scopeplus the owner in the query. - Input validation: NestJS
ValidationPipewithwhitelistandforbidNonWhitelisted. Expresszodwith.strict(). Hapi.js Joi inoptions.validate. - Security headers:
helmetin NestJS and Express.routes.security: truein Hapi.js. - Rate limiting:
@nestjs/throttler,express-rate-limitorhapi-rate-limit. - Replay protection: an HMAC signature over the request plus a nonce stored with Redis
SET NX, as a guard, middleware oronPreAuthextension. - Sensitive data:
ClassSerializerInterceptorwith@Exclude()in NestJS. In Express and Hapi.js, return only the fields you need. - File uploads: check type and size (
ParseFilePipe,multerlimits orpayload.maxBytes), then scan with ClamAV. - SSRF: resolve the host and block private and loopback IPs before any outbound call.
Conclusion
Implementing defense-in-depth is the only reliable way to keep your applications safe. By layering authentication, tight input validation, SSRF checks, and replay protections, a failure in one layer does not mean an instant compromise of your entire backend. Security is not a checklist, it is a mindset. If you are splitting a monolith into services, every new service needs these layers too; I wrote about that move in Monolith to Microservices. In my next post, I will walk through how we hardened the infrastructure layer underneath this application, including AWS VPC constraints and automated secret rotation. Stay tuned.
PS: If you have not checked your own project against the latest OWASP Top 10, take 20 minutes to do it today. You might be surprised what you find.