OWASP Security in Practice: Hardening Your Backend Against Real Threats

OWASP Security Practices for your backend code
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.
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.
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.
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;
}
}
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,
},
});
}
}
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}`);
}
}
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. 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.