Security Best Practices: OWASP Top 10 in the Node.js Context
For .NET engineers who know: OWASP Top 10, ASP.NET Core’s built-in security features (anti-forgery tokens, output encoding,
[Authorize]), Entity Framework parameterization, and Data Protection APIs You’ll learn: How each OWASP Top 10 vulnerability manifests differently in Node.js and TypeScript, how our toolchain detects each one, and the mitigation patterns specific to our stack Time: 15-20 min read
The .NET Way (What You Already Know)
ASP.NET Core ships with strong defaults for most OWASP Top 10 mitigations built into the framework. Entity Framework Core parameterizes queries by default. Razor automatically HTML-encodes output. [ValidateAntiForgeryToken] handles CSRF. [Authorize] enforces authentication at the controller or action level. The Data Protection API handles key management for tokens and cookies.
The Node.js ecosystem does not have a single framework with comparable built-in defaults. NestJS provides structure, but security is assembled from npm packages, middleware configuration, and explicit choices. The same vulnerabilities exist; some are harder to trigger accidentally, some are easier.
This article covers the OWASP Top 10 (2021 edition) in the context of our NestJS + Next.js + Vue stack. For each vulnerability: how it manifests in our stack, how the tools from this track detect it, and the mitigation pattern we use.
A01: Broken Access Control
How it manifests: Missing authorization checks on NestJS endpoints, insecure direct object references (IDOR) in API parameters, or client-side-only access control that is bypassed by direct API calls.
Detection: Semgrep can flag routes missing a guard. SonarCloud flags missing authorization checks on sensitive operations.
The .NET equivalent: [Authorize] on controllers, [AllowAnonymous] as an explicit opt-out, and resource-based authorization via IAuthorizationService.
Mitigation in NestJS:
// WRONG — public by default, hoping route obscurity protects it
@Controller('admin')
export class AdminController {
@Get('users')
getAllUsers() {
return this.usersService.findAll(); // Accessible to anyone
}
}
// CORRECT — guard at controller level, explicit opt-out at method level
@Controller('admin')
@UseGuards(ClerkAuthGuard, AdminRoleGuard) // Applied to ALL methods in this controller
export class AdminController {
@Get('users')
getAllUsers() {
return this.usersService.findAll();
}
@Get('health')
@Public() // Explicit opt-out decorator — requires discipline to use correctly
healthCheck() {
return { status: 'ok' };
}
}
IDOR — always verify ownership before returning data:
// WRONG — any authenticated user can read any order by guessing the ID
@Get(':id')
async getOrder(@Param('id') id: string, @CurrentUser() user: User) {
return this.ordersService.findById(id);
}
// CORRECT — enforce that the requesting user owns this resource
@Get(':id')
async getOrder(@Param('id') id: string, @CurrentUser() user: User) {
const order = await this.ordersService.findById(id);
if (!order) throw new NotFoundException();
// Ownership check — the critical line
if (order.userId !== user.id) throw new ForbiddenException();
return order;
}
A02: Cryptographic Failures
How it manifests: Storing passwords with weak hashing (MD5, SHA1), transmitting sensitive data over HTTP, logging secrets, or using JWT with alg: none.
Detection: Snyk flags packages with known weak cryptography. Semgrep rules catch md5, sha1, and alg: none patterns. SonarCloud flags hardcoded secrets and weak algorithms.
Mitigation:
// Password hashing — bcrypt is the standard
import * as bcrypt from 'bcrypt';
const SALT_ROUNDS = 12; // Not configurable per-environment — fix it
async function hashPassword(plaintext: string): Promise<string> {
return bcrypt.hash(plaintext, SALT_ROUNDS);
}
async function verifyPassword(plaintext: string, hash: string): Promise<boolean> {
return bcrypt.compare(plaintext, hash);
}
// WRONG — never use these for passwords
import * as crypto from 'crypto';
const hash = crypto.createHash('md5').update(password).digest('hex'); // No
const hash = crypto.createHash('sha1').update(password).digest('hex'); // No
const hash = crypto.createHash('sha256').update(password).digest('hex'); // No — unsalted
For JWT configuration, always specify the algorithm explicitly:
// WRONG — algorithm confusion attack: attacker changes header to "none" or "HS256"
// when the server expects RS256
jwt.verify(token, publicKey);
// CORRECT
jwt.verify(token, publicKey, { algorithms: ['RS256'] });
// Never accept "none" as an algorithm — it means no signature verification
// This should be enforced by always specifying the algorithm array
A03: Injection
SQL Injection
How it manifests: String concatenation or template literals in raw SQL queries. Prisma’s query builder is parameterized by default — SQL injection through Prisma requires deliberately bypassing it with $queryRaw.
Detection: Semgrep’s p/nodejs ruleset includes SQL injection patterns. SonarCloud’s security hotspot scanner flags template literals in SQL context.
// WRONG — template literal in raw SQL
const userId = req.body.userId;
const result = await prisma.$queryRaw`
SELECT * FROM users WHERE id = ${userId}
`;
// Wait — this is actually SAFE. Prisma's tagged template sanitizes parameters.
// ACTUALLY WRONG — string concatenation bypasses parameterization
const result = await prisma.$queryRawUnsafe(
`SELECT * FROM users WHERE id = ${userId}` // ← SQL injection
);
// CORRECT — always use $queryRaw (tagged template) not $queryRawUnsafe
const result = await prisma.$queryRaw`
SELECT * FROM users WHERE id = ${userId}
`;
// Or better, use Prisma's typed query builder (no raw SQL at all)
const user = await prisma.user.findUnique({ where: { id: userId } });
NoSQL Injection
This is a Node.js-specific concern with no direct .NET equivalent. MongoDB queries accept JavaScript objects, and if you construct those objects from user input, users can inject MongoDB query operators.
// WRONG — if req.body.username is { "$gt": "" }, this matches all users
const user = await db.collection('users').findOne({
username: req.body.username, // User-controlled MongoDB query object
password: req.body.password,
});
// CORRECT — validate and type the input with Zod before using it
import { z } from 'zod';
const LoginSchema = z.object({
username: z.string().min(1).max(100), // Only accepts strings
password: z.string().min(1),
});
const { username, password } = LoginSchema.parse(req.body);
const user = await db.collection('users').findOne({ username, password });
Command Injection
Node.js’s child_process module is dangerous. There is no safe child_process.exec() with user input — period.
import { exec, execFile } from 'child_process';
// WRONG — shell expansion allows command injection
const filename = req.body.filename;
exec(`convert ${filename} output.png`, callback);
// User sends: filename = "image.jpg; rm -rf /"
// WRONG — exec always runs through the shell
exec(`ffmpeg -i ${userInput} -o output.mp4`);
// CORRECT — execFile does not spawn a shell; user input is passed as arguments
execFile('convert', [filename, 'output.png'], callback);
// CORRECT — validate filename format before use
const SafeFilenameSchema = z.string().regex(/^[\w\-\.]+$/, 'Invalid filename');
const safeFilename = SafeFilenameSchema.parse(req.body.filename);
execFile('convert', [safeFilename, 'output.png'], callback);
// BEST — avoid child_process entirely; use native Node.js packages
// for the task (Sharp for images, FFmpeg bindings, etc.)
Detection: Semgrep’s p/nodejs ruleset flags child_process.exec with non-literal arguments. This is one of the highest-value Semgrep rules to enable.
A04: Insecure Design
How it manifests: Business logic flaws — race conditions, missing rate limiting, predictable resource IDs, or over-privileged API endpoints.
Mitigation patterns:
// Rate limiting — express-rate-limit (NestJS uses this via @nestjs/throttler)
import { ThrottlerModule } from '@nestjs/throttler';
// In AppModule
ThrottlerModule.forRoot({
ttl: 60, // Time window in seconds
limit: 100, // Max requests per window
}),
// Apply to specific endpoints (e.g., auth routes get tighter limits)
@UseGuards(ThrottlerGuard)
@Throttle({ default: { ttl: 60, limit: 5 } })
@Post('auth/login')
async login(@Body() dto: LoginDto) { ... }
A05: Security Misconfiguration
How it manifests: CORS configured to accept any origin, development error messages in production, missing security headers, or default credentials left in place.
Detection: Snyk flags packages with known misconfiguration issues. Semgrep rules catch cors({ origin: '*' }) patterns.
Mitigation:
// src/main.ts — production security configuration
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Helmet — sets security headers (equivalent to adding security middleware in ASP.NET)
// X-Content-Type-Options, X-Frame-Options, Strict-Transport-Security, etc.
app.use(helmet());
// CORS — restrict to known origins, never '*' in production
app.enableCors({
origin: process.env.ALLOWED_ORIGINS?.split(',') ?? ['http://localhost:3000'],
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
credentials: true, // Required for cookies / Authorization headers
});
// Never expose detailed errors in production
// NestJS's built-in exception filter omits stack traces in production
// Verify: NODE_ENV=production in your deployment environment
}
# Install helmet — security headers for Express/NestJS
pnpm add helmet
A06: Vulnerable and Outdated Components
This is covered in detail in Article 7.3 (Snyk). The summary:
snyk test --severity-threshold=highin CI blocks deployments on high/critical vulnerabilitiessnyk monitorprovides continuous monitoring for new CVEs against your deployed dependency snapshot- Lock files (
pnpm-lock.yaml) committed to the repository ensure deterministic installs - Scheduled weekly Snyk scans catch new CVEs on unchanged dependency trees
A07: Identification and Authentication Failures
How it manifests in Node.js: JWT algorithm confusion, missing token expiry validation, weak session secrets, token storage in localStorage (XSS-accessible), or missing brute-force protection.
Detection: Semgrep’s p/jwt ruleset catches algorithm confusion patterns. Snyk flags known-vulnerable versions of JWT libraries.
JWT pitfalls specific to Node.js:
// WRONG — algorithm confusion: attacker can change "alg" in JWT header
const decoded = jwt.verify(token, secret);
// WRONG — accepting multiple algorithms includes the dangerous ones
const decoded = jwt.verify(token, secret, { algorithms: ['HS256', 'RS256'] });
// If the server uses RS256 and you include HS256, an attacker can sign a
// token with the public key (which is, by definition, public) using HS256.
// CORRECT — exactly one algorithm, matched to your key type
const decoded = jwt.verify(token, publicKey, { algorithms: ['RS256'] });
// WRONG — no expiry check (exp claim ignored if not specified in options)
const decoded = jwt.decode(token); // decode() does NOT verify — common confusion
// CORRECT — verify() checks signature AND standard claims (exp, iat, etc.)
const decoded = jwt.verify(token, secret, {
algorithms: ['HS256'],
issuer: 'your-app',
audience: 'your-users',
});
We use Clerk for authentication — the above applies if you are implementing custom JWT handling. With Clerk, authentication is handled by @clerk/nextjs and @clerk/express — you verify sessions with the Clerk SDK, not raw JWT operations. Do not reimplement authentication from scratch if Clerk covers the use case.
Token storage:
- Clerk uses
HttpOnlycookies for session tokens — inaccessible to JavaScript, protected against XSS - If you manage your own JWTs, store them in
HttpOnlycookies, notlocalStorage localStorageis accessible to any JavaScript on the page — a single XSS vulnerability leaks all tokens
A08: Software and Data Integrity Failures
How it manifests: npm supply chain attacks, CI/CD pipeline compromise, deserialization of untrusted data without validation.
Mitigation:
# Always use frozen lockfile installs in CI — never install from package.json directly
pnpm install --frozen-lockfile # pnpm
npm ci # npm
# Enable package provenance verification (npm v9+, pnpm v8+)
npm config set audit-level=high
// WRONG — deserializing unknown data without validation
const data = JSON.parse(req.body.payload); // Trusting user-provided JSON structure
// CORRECT — validate with Zod after parsing
import { z } from 'zod';
const WebhookPayloadSchema = z.object({
event: z.string(),
data: z.object({
id: z.string().uuid(),
amount: z.number().positive(),
}),
});
const payload = WebhookPayloadSchema.parse(JSON.parse(req.body.payload));
A09: Security Logging and Monitoring Failures
How it manifests: Exceptions swallowed without logging, Sentry not configured, error messages that leak implementation details, or logs containing sensitive data.
Detection: Sentry (Article 7.1) handles error tracking. SonarCloud flags caught exceptions with empty catch blocks.
// WRONG — swallowing exceptions silently
try {
await processPayment(dto);
} catch {
// Nothing logged — this payment failure disappears completely
}
// WRONG — logging sensitive data
this.logger.error('Payment failed', {
cardNumber: dto.cardNumber, // PCI violation
cvv: dto.cvv,
});
// CORRECT — log the error without sensitive data, capture to Sentry
try {
await processPayment(dto);
} catch (error) {
this.logger.error('Payment processing failed', {
orderId: dto.orderId,
// No card data — log only non-sensitive context
});
Sentry.captureException(error, { extra: { orderId: dto.orderId } });
throw new InternalServerErrorException('Payment processing failed');
}
A10: Server-Side Request Forgery (SSRF)
How it manifests: Node.js’s fetch or axios called with user-controlled URLs. If your server fetches a URL provided by the user, an attacker can point it at internal services, AWS metadata endpoints, or other resources the server can reach but the user cannot.
This is a Node.js concern that rarely appears in .NET enterprise applications because most .NET apps use HttpClient with strongly-typed API clients. Node.js developers write fetch(userInput) more casually.
Detection: Semgrep rules that flag fetch($URL) or axios.get($URL) where $URL derives from request parameters.
// WRONG — fetch with user-controlled URL
@Post('preview')
async fetchPreview(@Body() dto: { url: string }) {
const response = await fetch(dto.url); // SSRF — user controls the target
return response.text();
}
// Attacker sends: { "url": "http://169.254.169.254/latest/meta-data/" }
// Server fetches the AWS metadata endpoint with the instance's credentials
// CORRECT — validate URL against an allowlist
import { z } from 'zod';
const AllowedOrigins = ['https://api.trusted-partner.com', 'https://cdn.example.com'];
const PreviewSchema = z.object({
url: z.string().url().refine(
(url) => AllowedOrigins.some((origin) => url.startsWith(origin)),
{ message: 'URL must be from an allowed origin' }
),
});
@Post('preview')
async fetchPreview(@Body() dto: { url: string }) {
const { url } = PreviewSchema.parse(dto);
const response = await fetch(url);
return response.text();
}
For more complex cases, block private IP ranges:
import { isIPv4 } from 'net';
import dns from 'dns/promises';
async function isSafeToFetch(urlString: string): Promise<boolean> {
const url = new URL(urlString);
// Block non-HTTP schemes
if (!['http:', 'https:'].includes(url.protocol)) return false;
// Resolve hostname and check for private IPs
const addresses = await dns.lookup(url.hostname, { all: true });
for (const addr of addresses) {
if (isPrivateIp(addr.address)) return false;
}
return true;
}
function isPrivateIp(ip: string): boolean {
return (
ip.startsWith('10.') ||
ip.startsWith('172.16.') ||
ip.startsWith('192.168.') ||
ip === '127.0.0.1' ||
ip === '::1' ||
ip.startsWith('169.254.') // AWS metadata
);
}
XSS: Special Attention
XSS deserves expanded treatment because the behavior differs significantly between React, Vue, and server-rendered HTML.
React: Auto-Escaping Is Your Default Protection
React escapes all dynamic content by default. This is the significant improvement over early web development:
// SAFE — React escapes this automatically
const UserProfile = ({ displayName }: { displayName: string }) => (
<div>Hello, {displayName}</div> // Rendered as text, not HTML
);
// UNSAFE — bypasses React's escaping
const RichContent = ({ html }: { html: string }) => (
<div dangerouslySetInnerHTML={{ __html: html }} /> // XSS if html is from user input
);
// CORRECT — sanitize before using dangerouslySetInnerHTML
import DOMPurify from 'dompurify';
const RichContent = ({ html }: { html: string }) => (
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }} />
);
# Install DOMPurify — the standard XSS sanitizer for the browser
pnpm add dompurify
pnpm add -D @types/dompurify
Detection: Semgrep’s p/react ruleset includes rules that flag dangerouslySetInnerHTML without DOMPurify.sanitize().
Vue: Similar Pattern with Different API
<!-- SAFE — auto-escaped -->
<template>
<div>{{ userContent }}</div>
</template>
<!-- UNSAFE — v-html is the Vue equivalent of dangerouslySetInnerHTML -->
<template>
<div v-html="userContent"></div> <!-- XSS if userContent is from user input -->
</template>
<!-- CORRECT — sanitize before binding -->
<template>
<div v-html="sanitizedContent"></div>
</template>
<script setup lang="ts">
import DOMPurify from 'dompurify';
const props = defineProps<{ userContent: string }>();
const sanitizedContent = computed(() => DOMPurify.sanitize(props.userContent));
</script>
Server-Side XSS (Next.js)
Next.js server components and API routes that return HTML content:
// WRONG — setting HTML response directly with user input
// (This pattern is rare in React apps but appears in API routes)
res.setHeader('Content-Type', 'text/html');
res.send(`<div>${req.query.name}</div>`); // XSS
// CORRECT — use a template that escapes, or return JSON
res.json({ name: req.query.name }); // JSON-encoded, safe
CSRF: SameSite Cookies vs. Anti-Forgery Tokens
The .NET way: [ValidateAntiForgeryToken] with @Html.AntiForgeryToken(). The framework generates a synchronizer token pair and validates it on state-changing requests.
The Node.js way: SameSite cookie attributes largely replace token-based CSRF protection for modern browsers. If you are using HttpOnly; Secure; SameSite=Strict or SameSite=Lax cookies for session management, CSRF is mitigated for the vast majority of attack scenarios.
// Session cookie configuration in NestJS (using express-session or similar)
app.use(
session({
cookie: {
httpOnly: true, // Prevents XSS access
secure: process.env.NODE_ENV === 'production', // HTTPS only in prod
sameSite: 'lax', // Prevents CSRF for most cases
maxAge: 24 * 60 * 60 * 1000, // 24 hours
},
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
})
);
If you use token-based auth (JWT in Authorization header, not cookies), CSRF is not a concern — cross-site requests cannot include the Authorization header due to CORS policy.
Clerk manages session cookies with appropriate security attributes by default — you do not need to configure this manually when using Clerk.
Quick Reference: Vulnerability → Mitigation → Detection
| OWASP Category | Node.js Risk | Mitigation | Tool Detection |
|---|---|---|---|
| A01: Broken Access Control | Missing route guards, IDOR | Guards on all controllers, ownership checks | Semgrep custom rules |
| A02: Cryptographic Failures | Weak hashing, JWT alg: none | bcrypt for passwords, specify JWT algorithm | Semgrep p/jwt, Snyk |
| A03: Injection — SQL | $queryRawUnsafe with user input | Use Prisma query builder or $queryRaw | Semgrep p/nodejs |
| A03: Injection — NoSQL | Object injection into MongoDB queries | Zod schema validation before query | Semgrep custom rules |
| A03: Injection — Command | exec() with user input | execFile() + input validation | Semgrep p/nodejs |
| A05: Misconfiguration | cors({ origin: '*' }), no security headers | Helmet, restrict CORS origins | Semgrep, SonarCloud |
| A06: Vulnerable Components | Transitive npm vulnerabilities | Snyk CI scan, --frozen-lockfile | Snyk |
| A07: Auth Failures | JWT algorithm confusion, localStorage token storage | Single algorithm, HttpOnly cookies | Semgrep p/jwt |
| A08: Data Integrity | Missing lockfile, deserialization without validation | npm ci / --frozen-lockfile, Zod parsing | Snyk monitor |
| A09: Logging Failures | Silent catch blocks, logging secrets | Structured logging, Sentry, no PII in logs | SonarCloud |
| A10: SSRF | fetch(userInput) | URL allowlist, private IP blocking | Semgrep custom rules |
| XSS (React) | dangerouslySetInnerHTML | DOMPurify.sanitize() | Semgrep p/react |
| XSS (Vue) | v-html with user content | DOMPurify.sanitize() in computed | Semgrep custom rules |
| CSRF | Cookie-based sessions | SameSite=Lax cookies | Manual review |
Hands-On Exercise
Conduct a security audit of one NestJS API module using the OWASP Top 10 as a checklist.
-
Pick a resource module (e.g.,
orders,users, or any existing CRUD module). -
Check A01: Does every route handler have an auth guard? Is there an ownership check before returning records? Try calling an endpoint with a different user’s resource ID.
-
Check A03: Find every place a database query is constructed. Are any using string concatenation or
$queryRawUnsafe? Find every use ofchild_process— does any receive user input? -
Check A05: Run
curl -I http://localhost:3000/api/healthand inspect response headers. Are security headers present (X-Content-Type-Options, X-Frame-Options)? Installhelmetif missing. -
Check A07: Find your JWT validation code. Does it specify an explicit algorithm? Is the
expclaim validated? Are tokens stored in HttpOnly cookies or localStorage? -
Check A10: Search for
fetch(andaxios.get(calls where the URL is not a literal string. Is any URL value derived from request data? -
Run Semgrep with the security-focused rulesets and compare findings to your manual audit:
semgrep --config=p/nodejs --config=p/react --config=p/owasp-top-ten src/
Further Reading
- OWASP Top 10 2021 — Authoritative descriptions of each category with technical details
- OWASP Node.js Security Cheat Sheet — Node.js-specific guidance for each category
- Helmet.js Documentation — Security headers middleware for Express and NestJS
- NestJS Security Documentation — NestJS-specific security patterns including Helmet, CORS, CSRF, and rate limiting