Securing Node.js APIs: Implementing CSP, Sanitization, and Defending Against Injection Attacks
A practical, code-heavy guide on hardening Node.js web applications against OWASP Top 10 vulnerabilities, focusing on Content Security Policies, robust input sanitization, and parameterized queries.
Securing Node.js APIs: Implementing CSP, Sanitization, and Defending Against Injection Attacks
Building fast and scalable APIs with Node.js and Express is remarkably easy. However, the flexibility and asynchronous nature of the ecosystem also mean that security misconfigurations can lead to catastrophic vulnerabilities. Year after year, the OWASP Top 10 highlights Injection, Cross-Site Scripting (XSS), and broken authorization as primary vectors for application compromise.
In this guide, we will move past basic tutorials and implement a defense-in-depth strategy for a production-grade Node.js API. We will configure a strict Content Security Policy (CSP), sanitize incoming user input, and enforce parameterized queries to stop SQL injection in its tracks.
1. Establishing the Baseline: Security Headers with Helmet
Before diving into custom logic, your Node.js application must set proper HTTP response headers to prevent common browser-based attacks. The gold standard for this in the Express ecosystem is Helmet.
Helmet is not a silver bullet, but it automatically configures headers like X-Frame-Options, X-Content-Type-Options, and Strict-Transport-Security.
Installation
npm install express helmet
Basic Setup
const express = require('express');
const helmet = require('helmet');
const app = express();
// Apply default security headers
app.app.use(helmet());
app.get('/api/health', (req, res) => {
res.json({ status: 'healthy' });
});
app.listen(3000, () => console.log('Server running on port 3000'));
2. Implementing a Robust Content Security Policy (CSP)
Cross-Site Scripting (XSS) relies on injecting malicious scripts into trusted websites. A robust Content Security Policy (CSP) restricts the domains from which scripts, styles, and other resources can be loaded and executed.
While Helmet provides a default CSP, modern APIs serving single-page applications (SPAs) or rendering server-side views require tailored policies. We will configure a strict CSP that disallows inline scripts (unsafe-inline) unless protected by nonces, and restricts resource loading to trusted origins.
Advanced CSP Configuration
const express = require('express');
const helmet = require('helmet');
const crypto = require('crypto');
const app = express();
// Generate a dynamic nonce for every request
app.use((req, res, next) => {
res.locals.cspNonce = crypto.randomBytes(16).toString('hex');
next();
});
// Configure Helmet CSP
app.use(
helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: [
"'self'",
(req, res) => `'nonce-${res.locals.cspNonce}'`,
'https://trusted-cdn.com',
],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https://images.example.com'],
connectSrc: ["'self'", 'https://api.example.com'],
objectSrc: ["'none'"],
upgradeInsecureRequests: [],
},
})
);
app.get('/', (req, res) => {
// Pass the nonce to your view engine
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>Secure App</title>
</head>
<body>
<h1>Welcome</h1>
<script nonce="${res.locals.cspNonce}">
console.log("This inline script is allowed because of the nonce.");
</script>
</body>
</html>
`);
});
Best Practice: Avoid
'unsafe-eval'and'unsafe-inline'in yourscriptSrcdirective. If you must run inline scripts, use cryptographic nonces or hashes as shown above.
3. Input Sanitization and Validation
Never trust user input. Whether it originates from a query parameter, request body, or headers, unvalidated data is the primary entry point for XSS, NoSQL injection, and prototype pollution.
We will use two industry-standard libraries:
express-validatorfor structural validation and sanitization.DOMPurify(viajsdom) orsanitize-htmlfor stripping malicious HTML from rich-text inputs.
Installation
npm install express-validator sanitize-html
Sanitization Middleware Implementation
Let’s create a robust endpoint that validates user profile updates, strips dangerous HTML tags, and escapes string inputs.
const express = require('express');
const { body, validationResult } = require('express-validator');
const sanitizeHtml = require('sanitize-html');
const app = express();
app.use(express.json());
// Custom sanitizer wrapper for sanitize-html
const cleanHtml = (value) => {
return sanitizeHtml(value, {
allowedTags: ['b', 'i', 'em', 'strong', 'a'],
allowedAttributes: {
'a': ['href']
}
});
};
app.post(
'/api/user/profile',
[
// 1. Validate structure and types
body('username')
.trim()
.isLength({ min: 3, max: 30 })
.withMessage('Username must be between 3 and 30 characters')
.escape(), // Escapes HTML entities
body('email')
.isEmail()
.normalizeEmail()
.withMessage('Invalid email address'),
body('bio')
.optional()
.customSanitizer(cleanHtml) // Sanitize rich text safely
],
(req, res) => {
// 2. Check for validation errors
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400.json({ errors: errors.array() }));
}
const { username, email, bio } = req.body;
// Data is now sanitized and validated
// Proceed to database operation...
return res.status(200).json({
message: 'Profile updated successfully',
data: { username, email, bio }
});
}
);
4. Defending Against Injection Attacks (SQL & NoSQL)
Injection vulnerabilities occur when untrusted data is sent to an interpreter as part of a command or query. Attackers can manipulate this data to execute unintended commands or access unauthorized data.
Defending Against SQL Injection
The golden rule of preventing SQL injection is simple: Never concatenate user input directly into SQL strings. Always use parameterized queries or an ORM/Query Builder that handles parameterization under the hood.
Here is how to do this correctly using pg (node-postgres):
const { Pool } = require('pg');
const pool = new Pool();
// ❌ VULNERABLE CODE (DO NOT USE)
app.get('/api/products-unsafe', async (req, res) => {
const { category } = req.query;
try {
// Direct string concatenation allows SQL Injection
const query = `SELECT * FROM products WHERE category = '${category}'`;
const result = await pool.query(query);
res.json(result.rows);
} catch (err) {
res.status(500).send('Server Error');
}
});
// ✅ SECURE CODE (Parameterized Query)
app.get('/api/products-safe', async (req, res) => {
const { category } = req.query;
try {
// Parameters are passed separately from the query structure
const query = 'SELECT * FROM products WHERE category = $1';
const result = await pool.query(query, [category]);
res.json(result.rows);
} catch (err) {
res.status(500).send('Server Error');
}
});
Defending Against NoSQL Injection (MongoDB / Mongoose)
NoSQL injection happens when query objects are manipulated via malicious JSON payloads (e.g., passing { $gt: "" } instead of a string username).
const mongoose = require('mongoose');
const UserSchema = new mongoose.Schema({
username: String,
passwordHash: String
});
const User = mongoose.model('User', UserSchema);
// ❌ VULNERABLE CODE
app.post('/api/login-unsafe', async (req, res) => {
try {
// If req.body.username is { $gt: "" }, this query returns the first user in the DB!
const user = await User.findOne({ username: req.body.username });
if (!user) return res.status(401).send('Unauthorized');
res.json({ message: 'Logged in' });
} catch (err) {
res.status(500).send('Server Error');
}
});
// ✅ SECURE CODE
app.post('/api/login-safe', async (req, res) => {
try {
const { username } = req.body;
// Explicitly enforce that username must be a primitive string
if (typeof username !== 'string') {
return res.status(400).send('Invalid input type');
}
const user = await User.findOne({ username: username });
if (!user) return res.status(401).send('Unauthorized');
res.json({ message: 'Logged in' });
} catch (err) {
res.status(500).send('Server Error');
}
});
Summary Checklist for Production Node.js APIs
To ensure your Node.js applications remain resilient against sophisticated attackers, keep this checklist handy:
- Use Helmet to establish secure HTTP headers out-of-the-box.
- Enforce a strict CSP with nonces or hashes, avoiding
unsafe-evalandunsafe-inline. - Validate and Sanitize all incoming parameters using tools like
express-validatorandsanitize-html. - Never concatenate user input into SQL queries; always use parameterized statements or secure ORMs.
- Enforce strict type checking on incoming NoSQL request bodies to prevent operator injection attacks.
By layering these defensive measures, you significantly raise the bar for attackers and build resilient, enterprise-ready Node.js APIs.