July 18, 2026
OWASP Top 10: What Every Developer Should Actually Know
Security is one of those topics most developers understand in theory but skip in practice.

By Abdur Rakib
7 min read
We know we should hash passwords. We know SQL injection is bad. We've heard of OWASP. But when there's a deadline, security becomes "something to handle later" — and later never quite arrives.
This post is what I put together while going deep on web application security. Every item on the OWASP Top 10 list, explained the way I wish someone had explained it to me — with real Node.js and NestJS examples, not abstract warnings.
If you're building backend APIs, this is the checklist you actually want.
What Is the OWASP Top 10?
The Open Web Application Security Project (OWASP) publishes a ranked list of the most critical web application security risks, updated regularly based on data from real-world breaches across thousands of applications.
It's not a list of exotic attack scenarios reserved for nation-state hackers. It's a list of mistakes that happen in normal codebases built by experienced developers — because security wasn't part of the design.
Let's go through all ten.
1. Broken Access Control
What it means: A user can access data or perform actions that belong to someone else or require higher privileges.
The classic example: you're logged in as a user 123. You notice the URL says /api/users/123. You change it to /api/users/124. If the server returns that user's data, that's a broken access control.
Vulnerable code:
@Get(':id')
getUser(@Param('id') id: string) {
return this.userService.findOne(id);
// No check: is this the requesting user's own ID?
}@Get(':id')
getUser(@Param('id') id: string) {
return this.userService.findOne(id);
// No check: is this the requesting user's own ID?
}Any authenticated user can fetch any other user's data by guessing or incrementing an ID.
The fix — never trust the client for identity:
@Get('me')
getMyProfile(@Request() req) {
// ID comes from the verified JWT, not from the URL
return this.userService.findOne(req.user.id);
}@Get('me')
getMyProfile(@Request() req) {
// ID comes from the verified JWT, not from the URL
return this.userService.findOne(req.user.id);
}For privileged routes, enforce role checks server-side:
@UseGuards(RolesGuard)
@Roles('admin')
@Get('/users')
getAllUsers() {
return this.userService.findAll();
}@UseGuards(RolesGuard)
@Roles('admin')
@Get('/users')
getAllUsers() {
return this.userService.findAll();
}The rule: the server decides what a user can see — never the URL, never the frontend.
2. Cryptographic Failures
What it means: Sensitive data — passwords, tokens, personal information — is stored or transmitted without proper protection.
Common mistakes include storing passwords in plain text or with weak hashes like MD5, making API calls over HTTP instead of HTTPS, logging or storing tokens in localStorage, and exposing sensitive fields in API responses.
Never do this:
const user = await db.save({ email, password }); // Plain text passwordconst user = await db.save({ email, password }); // Plain text passwordAlways hash passwords with bcrypt:
import * as bcrypt from 'bcrypt';
const hashedPassword = await bcrypt.hash(password, 10);
const user = await db.save({ email, password: hashedPassword });
// Verification
const isValid = await bcrypt.compare(inputPassword, user.password);import * as bcrypt from 'bcrypt';
const hashedPassword = await bcrypt.hash(password, 10);
const user = await db.save({ email, password: hashedPassword });
// Verification
const isValid = await bcrypt.compare(inputPassword, user.password);bcrypt is intentionally slow. That slowness is what makes brute-force attacks impractical — a GPU that cracks an MD5 hash in milliseconds takes years on a properly configured bcrypt hash.
Beyond passwords: always use HTTPS, never log sensitive fields, and encrypt PII at rest.
3. Injection
What it means: Untrusted user input gets interpreted as part of a query or command, changing what it does.
SQL injection is the most well-known variant — but the same problem exists with NoSQL queries, shell commands, XML parsers, and anywhere else you mix user input with an interpreted language.
Vulnerable — string concatenation into a query:
// If email = "' OR '1'='1", this returns every user in the database
const result = await db.query(
`SELECT * FROM users WHERE email='${email}'`
);// If email = "' OR '1'='1", this returns every user in the database
const result = await db.query(
`SELECT * FROM users WHERE email='${email}'`
);Fixed — parameterized query:
const result = await db.query(
'SELECT * FROM users WHERE email = $1',
[email] // Treated as data, never executed as SQL
);const result = await db.query(
'SELECT * FROM users WHERE email = $1',
[email] // Treated as data, never executed as SQL
);Better — use an ORM like Prisma:
// Prisma handles parameterization automatically
const user = await prisma.user.findFirst({
where: { email }
});// Prisma handles parameterization automatically
const user = await prisma.user.findFirst({
where: { email }
});The principle: never concatenate user input into anything that gets interpreted or executed.
4. Insecure Design
What it means: The application's architecture is missing fundamental security controls — not a bug in the code, but a gap in the design.
Examples: no rate limiting on login, no account lockout after failed attempts, no verification step before a password reset, no separation between admin and user capabilities.
You can't patch your way out of insecure design. It has to be built in.
The most common missing control — rate limiting on auth routes:
// npm install @nestjs/throttler
ThrottlerModule.forRoot([{
ttl: 60000, // 60-second window
limit: 5, // 5 attempts max per window
}])
@UseGuards(ThrottlerGuard)
@Post('/login')
async login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}// npm install @nestjs/throttler
ThrottlerModule.forRoot([{
ttl: 60000, // 60-second window
limit: 5, // 5 attempts max per window
}])
@UseGuards(ThrottlerGuard)
@Post('/login')
async login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}The habit to build: during feature design, ask — what's the worst thing someone could do with this, and what stops them?
5. Security Misconfiguration
What it means: Insecure default settings, exposed debug tooling, permissive CORS, or verbose error messages that hand attackers information they shouldn't have.
Common examples in Node.js projects:
- Swagger UI is exposed publicly in production
NODE_ENVnot set toproduction- CORS set to *
- Stack traces returned in error responses
- Default credentials left unchanged
The baseline security setup for any NestJS app:
// main.ts
import helmet from 'helmet';
// Security headers — covers XSS, clickjacking, MIME sniffing, and more
app.use(helmet());
// Lock CORS to your actual domains
app.enableCors({
origin: ['https://myapp.com'],
credentials: true,
});
// Only expose API docs in development
if (process.env.NODE_ENV !== 'production') {
const config = new DocumentBuilder().setTitle('API').build();
SwaggerModule.setup('docs', app, SwaggerModule.createDocument(app, config));
}// main.ts
import helmet from 'helmet';
// Security headers — covers XSS, clickjacking, MIME sniffing, and more
app.use(helmet());
// Lock CORS to your actual domains
app.enableCors({
origin: ['https://myapp.com'],
credentials: true,
});
// Only expose API docs in development
if (process.env.NODE_ENV !== 'production') {
const config = new DocumentBuilder().setTitle('API').build();
SwaggerModule.setup('docs', app, SwaggerModule.createDocument(app, config));
}helmet() alone sets over a dozen protective HTTP headers. It costs one line to add and should be in every app you ship.
6. Vulnerable and Outdated Components
What it means: You're using dependencies with known security vulnerabilities. This is one of the most common ways production systems get compromised — not through the code you wrote, but through a library you installed.
A real pattern: an old version of a popular utility library has a prototype pollution vulnerability. It was patched six months ago. You haven't updated. You're running the vulnerable version.
Make dependency auditing part of your routine:
# Check for vulnerabilities
npm audit
# Auto-fix safe updates
npm audit fix# Check for vulnerabilities
npm audit
# Auto-fix safe updates
npm audit fixSet up Dependabot on GitHub (free). It automatically opens pull requests when your dependencies have security patches. Review, merge, done.
Also worth doing regularly: remove packages you no longer use. Every dependency you don't need is a vulnerability you don't have to worry about.
7. Identification and Authentication Failures
What it means: Weak authentication mechanisms allow attackers to impersonate legitimate users. This includes JWTs that never expire, weak password requirements, missing MFA, and improperly stored session tokens.
The wrong approach:
// Token with no expiry — valid indefinitely if stolen
jwt.sign(payload, secret);// Token with no expiry — valid indefinitely if stolen
jwt.sign(payload, secret);The correct pattern — short-lived access tokens with refresh tokens:
// Access token: short-lived, stateless
const accessToken = jwt.sign(payload, secret, { expiresIn: '15m' });
// Refresh token: longer-lived, stored in httpOnly cookie, revocable server-side
const refreshToken = jwt.sign(
{ userId: user.id },
refreshSecret,
{ expiresIn: '7d' }
);// Access token: short-lived, stateless
const accessToken = jwt.sign(payload, secret, { expiresIn: '15m' });
// Refresh token: longer-lived, stored in httpOnly cookie, revocable server-side
const refreshToken = jwt.sign(
{ userId: user.id },
refreshSecret,
{ expiresIn: '7d' }
);If an access token gets stolen, the attacker's window is 15 minutes. Refresh tokens stored in httpOnly cookies are invisible to JavaScript — immune to XSS attacks — and can be invalidated when a user logs out.
Other controls to layer in: enforce minimum password length and complexity, block commonly used passwords, and implement MFA for sensitive operations.
8. Software and Data Integrity Failures
What it means: Your application runs code or data from external sources without verifying that it hasn't been tampered with.
Two common scenarios:
1. CDN compromise — you load a script from a third-party CDN. If that CDN is compromised, every user of your app runs malicious code.
2. Supply chain attack — a dependency you rely on gets a new maintainer who publishes a malicious version. You install it without noticing.
Fix for CDN scripts — Subresource Integrity (SRI):
<script
src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"
integrity="sha384-[hash-of-the-file]"
crossorigin="anonymous">
</script><script
src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"
integrity="sha384-[hash-of-the-file]"
crossorigin="anonymous">
</script>The browser hashes the downloaded file and compares it to the integrity attribute. A single changed byte causes the script to be blocked entirely.
Fix for build pipelines:
# Use ci instead of install — respects the lockfile exactly
npm ci# Use ci instead of install — respects the lockfile exactly
npm ciCommit your lockfile (package-lock.json or pnpm-lock.yaml) and never bypass it in CI.
9. Security Logging and Monitoring Failures
What it means: Critical security events aren't logged, aren't centralized, or aren't triggering alerts. Without proper monitoring, a breach can go undetected for months.
The events worth logging: authentication attempts (both success and failure), role changes, password resets, access to sensitive data, and unusual request patterns.
Structured logging on auth events:
@Post('/login')
async login(@Body() dto: LoginDto, @Request() req) {
const user = await this.authService.validateUser(dto);
if (!user) {
this.logger.warn('Authentication failed', {
email: dto.email,
ip: req.ip,
timestamp: new Date().toISOString(),
});
throw new UnauthorizedException();
}
this.logger.log('Authentication succeeded', {
userId: user.id,
ip: req.ip,
});
return this.authService.generateTokens(user);
}@Post('/login')
async login(@Body() dto: LoginDto, @Request() req) {
const user = await this.authService.validateUser(dto);
if (!user) {
this.logger.warn('Authentication failed', {
email: dto.email,
ip: req.ip,
timestamp: new Date().toISOString(),
});
throw new UnauthorizedException();
}
this.logger.log('Authentication succeeded', {
userId: user.id,
ip: req.ip,
});
return this.authService.generateTokens(user);
}Ship those logs to a centralized system — ELK stack, Loki, CloudWatch, or Datadog — and set alerts for patterns: five failed logins from one IP in two minutes, an admin accessing bulk records, a role elevation outside business hours.
Logging doesn't prevent attacks. It limits how long an attack goes unnoticed.
10. Server-Side Request Forgery (SSRF)
What it means: Your server makes HTTP requests to a URL provided by a user, and doesn't validate where that URL points. An attacker can use this to make your server talk to internal services it should never be able to reach.
A well-known example: AWS instances have an internal metadata endpoint 169.254.169.254 that returns IAM credentials. If your server fetches any URL, an attacker can point it there.
Vulnerable:
@Get('/fetch')
async fetch(@Query('url') url: string) {
const response = await fetch(url); // Will fetch anything, including internal services
return response.text();
}@Get('/fetch')
async fetch(@Query('url') url: string) {
const response = await fetch(url); // Will fetch anything, including internal services
return response.text();
}Fixed — strict domain allowlist:
import { URL } from 'url';
import { BadRequestException } from '@nestjs/common';
const ALLOWED_HOSTS = new Set(['images.myapp.com', 'cdn.trusted-source.com']);
@Get('/fetch')
async fetch(@Query('url') rawUrl: string) {
let url: URL;
try {
url = new URL(rawUrl);
} catch {
throw new BadRequestException('Invalid URL');
}
if (!['http:', 'https:'].includes(url.protocol)) {
throw new BadRequestException('Protocol not allowed');
}
if (!ALLOWED_HOSTS.has(url.hostname)) {
throw new BadRequestException('Host not allowed');
}
const response = await fetch(url.toString());
return response.text();
}import { URL } from 'url';
import { BadRequestException } from '@nestjs/common';
const ALLOWED_HOSTS = new Set(['images.myapp.com', 'cdn.trusted-source.com']);
@Get('/fetch')
async fetch(@Query('url') rawUrl: string) {
let url: URL;
try {
url = new URL(rawUrl);
} catch {
throw new BadRequestException('Invalid URL');
}
if (!['http:', 'https:'].includes(url.protocol)) {
throw new BadRequestException('Protocol not allowed');
}
if (!ALLOWED_HOSTS.has(url.hostname)) {
throw new BadRequestException('Host not allowed');
}
const response = await fetch(url.toString());
return response.text();
}Use an allowlist, not a blocklist. Trying to block all internal IP ranges can be bypassed with DNS rebinding. Allowing only hosts you explicitly trust cannot.
The Minimum Every NestJS App Should Have
After going through all ten, here's the practical baseline I apply to every Node.js backend I build:
npm install @nestjs/throttler helmet bcrypt @nestjs/confignpm install @nestjs/throttler helmet bcrypt @nestjs/configIn main.ts:
app.use(helmet());
app.enableCors({ origin: ['https://yourdomain.com'], credentials: true });app.use(helmet());
app.enableCors({ origin: ['https://yourdomain.com'], credentials: true });On auth routes: ThrottlerGuard, short-lived JWTs, bcrypt for password hashing.
In CI: npm audit on every pull request, Dependabot is enabled, npm ci instead of npm install.
In production: structured logging, centralized log aggregation, alerts on auth anomalies.
None of this is hard to implement. The difficulty is remembering to do it before you ship, not after.
Summary
Security isn't a feature you add at the end. It's a set of habits you build into how you work. The OWASP Top 10 is a good place to start building them.
I'm a software engineer working on backend systems. I write about Node.js, NestJS, system design, and things I'm learning along the way.