You build an application. It works. Users love it. Then someone finds a vulnerability. SQL injection. Your database wiped. Or XSS attack. User data stolen. Or brute force. Accounts compromised. You didn't know these attacks existed.
You read about security. Too many concepts. SQL injection, XSS, CSRF, session hijacking, clickjacking, DDoS, man-in-the-middle. You don't understand half of them. How do you protect against all this?
You add some security features. Copy code from tutorials. Helmet middleware here. CORS there. Rate limiting somewhere. You don't know if it's enough. You don't know if it's configured correctly. You don't know what you're missing.
Security is not optional. Attackers scan for vulnerabilities constantly. One hole compromises everything. But security doesn't have to be overwhelming. Learn the fundamentals. Apply proven patterns. Protect your application.
QUICK SUMMARY
What You'll Learn: Web application security protects against common attacks. SQL injection uses malicious SQL in inputs; prevent with parameterized queries. XSS injects scripts into pages; prevent with input sanitization and escaping output. CSRF tricks users into unwanted actions; prevent with CSRF tokens. Brute force attacks guess passwords; prevent with rate limiting and account lockout. Always use HTTPS to encrypt data in transit. Hash passwords with bcrypt, never store plaintext. Validate and sanitize all user input. Use Helmet middleware for security headers. Store secrets in environment variables. Implement proper authentication and authorization. Keep dependencies updated. Use CSP (Content Security Policy) to prevent XSS. Enable CORS properly. Log security events. Regular security audits find vulnerabilities.
Why It Matters:
- Data breaches destroy trust
- Legal liability for security failures
- User data must be protected
- Attacks happen constantly
- One vulnerability compromises everything
- Security is everyone's responsibility
- Prevention cheaper than recovery
Key Concepts:
- SQL Injection prevention
- XSS (Cross-Site Scripting) protection
- CSRF (Cross-Site Request Forgery) tokens
- Rate limiting and brute force protection
- HTTPS and SSL/TLS
- Password security with bcrypt
- Input validation and sanitization
- Security headers with Helmet
What You'll Build:
- Secure authentication system
- Input validation layer
- Rate limiting implementation
- CSRF protection
- XSS prevention
- SQL injection prevention
- Complete security middleware stack
Time Investment: Understanding basics takes 2–3 hours. Implementing full security is ongoing.
Prerequisites:
- Express.js (Post 38)
- Middleware basics (Post 40)
- Authentication (Posts 47–48)
- Database operations
FULL DETAILED ARTICLE
OWASP Top 10
The most critical web security risks:
1. Broken Access Control
2. Cryptographic Failures
3. Injection (SQL, NoSQL, Command)
4. Insecure Design
5. Security Misconfiguration
6. Vulnerable and Outdated Components
7. Identification and Authentication Failures
8. Software and Data Integrity Failures
9. Security Logging and Monitoring Failures
10. Server-Side Request Forgery (SSRF)We'll cover the most critical ones for Node.js applications.
SQL Injection Prevention
Attack example:
// VULNERABLE - Never do this
const email = req.body.email;
const query = `SELECT * FROM users WHERE email = '${email}'`;
const user = await db.query(query);
// Attacker sends: admin'--
// Query becomes: SELECT * FROM users WHERE email = 'admin'--'
// The -- comments out the rest, bypassing authenticationPrevention with parameterized queries:
// Safe - parameterized query
const email = req.body.email;
const query = 'SELECT * FROM users WHERE email = $1';
const user = await db.query(query, [email]);
// Or with Mongoose
const user = await User.findOne({ email: req.body.email });
// Or with Prisma
const user = await prisma.user.findUnique({
where: { email: req.body.email }
});NoSQL injection prevention:
// Vulnerable
const user = await User.findOne({
email: req.body.email,
password: req.body.password
});
// Attacker sends: { "password": { "$ne": null } }
// Finds user with any password
// Safe - validate input types
const { email, password } = req.body;
if (typeof email !== 'string' || typeof password !== 'string') {
return res.status(400).json({ error: 'Invalid input' });
}
const user = await User.findOne({ email }).select('+password');
if (!user || !await user.comparePassword(password)) {
return res.status(401).json({ error: 'Invalid credentials' });
}XSS (Cross-Site Scripting) Prevention
Attack example:
// Vulnerable - renders user input directly
app.get('/search', (req, res) => {
const query = req.query.q;
res.send(`<h1>Search results for: ${query}</h1>`);
});
// Attacker visits: /search?q=<script>alert(document.cookie)</script>
// Script executes, steals cookiesPrevention:
// 1. Escape output (template engines do this)
app.set('view engine', 'ejs');
app.get('/search', (req, res) => {
const query = req.query.q;
res.render('search', { query }); // EJS automatically escapes
});
// 2. Sanitize input
const sanitizeHtml = require('sanitize-html');
app.post('/comment', (req, res) => {
const clean = sanitizeHtml(req.body.comment, {
allowedTags: ['b', 'i', 'em', 'strong'],
allowedAttributes: {}
});
await Comment.create({ text: clean });
res.json({ success: true });
});
// 3. Content Security Policy
const helmet = require('helmet');
app.use(
helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https:'],
}
})
);
// 4. Set X-XSS-Protection header
app.use(helmet.xssFilter());React automatically escapes:
// Safe in React - automatically escaped
function SearchResults({ query }) {
return <h1>Search results for: {query}</h1>;
}
// Dangerous - only use if you trust the content
function DangerousHTML({ html }) {
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}CSRF (Cross-Site Request Forgery) Prevention
Attack example:
<!-- Attacker's website -->
<img src="http://yourbank.com/transfer?to=attacker&amount=1000" />
<!-- If user is logged in, this executes the transfer -->Prevention with CSRF tokens:
const csrf = require('csurf');
const cookieParser = require('cookie-parser');
app.use(cookieParser());
// CSRF protection
const csrfProtection = csrf({ cookie: true });
// Send CSRF token to client
app.get('/form', csrfProtection, (req, res) => {
res.render('form', { csrfToken: req.csrfToken() });
});
// Validate CSRF token on POST
app.post('/transfer', csrfProtection, (req, res) => {
// Token automatically validated
// If invalid, error thrown
performTransfer(req.body);
res.json({ success: true });
});
// HTML form
/*
<form method="POST" action="/transfer">
<input type="hidden" name="_csrf" value="<%= csrfToken %>" />
<input type="text" name="amount" />
<button type="submit">Transfer</button>
</form>
*/For APIs (use SameSite cookies):
// Set SameSite attribute on cookies
res.cookie('token', token, {
httpOnly: true,
secure: true,
sameSite: 'strict' // Prevents CSRF
});Rate Limiting
Prevent brute force and DoS attacks:
const rateLimit = require('express-rate-limit');
// General rate limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
message: 'Too many requests, please try again later',
standardHeaders: true,
legacyHeaders: false,
});
app.use('/api', limiter);
// Strict limiting for auth endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5, // Only 5 login attempts per 15 minutes
skipSuccessfulRequests: true,
message: 'Too many login attempts, please try again later'
});
app.post('/api/auth/login', authLimiter, login);
// Per-user rate limiting
const Redis = require('ioredis');
const redis = new Redis();
async function userRateLimit(req, res, next) {
const userId = req.user?.id || req.ip;
const key = `rate:${userId}`;
const requests = await redis.incr(key);
if (requests === 1) {
await redis.expire(key, 60); // 1 minute window
}
if (requests > 10) {
return res.status(429).json({ error: 'Rate limit exceeded' });
}
next();
}
app.use('/api', userRateLimit);Account lockout after failed attempts:
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await User.findOne({ email }).select('+password +loginAttempts +lockUntil');
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Check if account is locked
if (user.lockUntil && user.lockUntil > Date.now()) {
return res.status(423).json({ error: 'Account temporarily locked' });
}
// Check password
const isMatch = await user.comparePassword(password);
if (!isMatch) {
// Increment failed attempts
user.loginAttempts = (user.loginAttempts || 0) + 1;
// Lock account after 5 failed attempts
if (user.loginAttempts >= 5) {
user.lockUntil = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes
}
await user.save();
return res.status(401).json({ error: 'Invalid credentials' });
}
// Reset attempts on successful login
user.loginAttempts = 0;
user.lockUntil = undefined;
await user.save();
// Generate token...
res.json({ user, token });
});Security Headers with Helmet
const helmet = require('helmet');
// Use all default protections
app.use(helmet());
// Or configure individually
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
mediaSrc: ["'self'"],
frameSrc: ["'none'"],
}
}));
app.use(helmet.dnsPrefetchControl());
app.use(helmet.frameguard({ action: 'deny' }));
app.use(helmet.hidePoweredBy());
app.use(helmet.hsts({ maxAge: 31536000 }));
app.use(helmet.ieNoOpen());
app.use(helmet.noSniff());
app.use(helmet.permittedCrossDomainPolicies());
app.use(helmet.referrerPolicy({ policy: 'no-referrer' }));
app.use(helmet.xssFilter());Headers explained:
X-Content-Type-Options: nosniff
Prevents MIME type sniffing
X-Frame-Options: DENY
Prevents clickjacking
Strict-Transport-Security: max-age=31536000
Forces HTTPS
X-XSS-Protection: 1; mode=block
Enables browser XSS protection
Content-Security-Policy: default-src 'self'
Controls resource loading
Referrer-Policy: no-referrer
Controls referrer informationHTTPS and SSL/TLS
Always use HTTPS in production:
// Development with self-signed certificate
const https = require('https');
const fs = require('fs');
const options = {
key: fs.readFileSync('key.pem'),
cert: fs.readFileSync('cert.pem')
};
https.createServer(options, app).listen(443);
// Production with Let's Encrypt
// Use reverse proxy (nginx) for SSL terminationForce HTTPS:
// Redirect HTTP to HTTPS
app.use((req, res, next) => {
if (req.header('x-forwarded-proto') !== 'https' && process.env.NODE_ENV === 'production') {
res.redirect(`https://${req.header('host')}${req.url}`);
} else {
next();
}
});Secure cookies (HTTPS only):
res.cookie('token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production', // HTTPS only
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000
});Input Validation and Sanitization
Validate all user input:
const { z } = require('zod');
const registerSchema = z.object({
email: z.string().email().max(255),
password: z.string().min(8).max(100),
name: z.string().min(2).max(50).trim()
});
app.post('/register', async (req, res) => {
try {
const validated = registerSchema.parse(req.body);
// Use validated data
const user = await User.create(validated);
res.json({ user });
} catch (err) {
if (err instanceof z.ZodError) {
return res.status(422).json({ errors: err.errors });
}
throw err;
}
});Sanitize HTML:
const sanitizeHtml = require('sanitize-html');
function sanitizeUserInput(html) {
return sanitizeHtml(html, {
allowedTags: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
allowedAttributes: {
'a': ['href']
},
allowedSchemes: ['http', 'https', 'mailto']
});
}
app.post('/comment', (req, res) => {
const clean = sanitizeUserInput(req.body.comment);
// Save clean comment
});Prevent path traversal:
const path = require('path');
app.get('/file/:filename', (req, res) => {
const filename = req.params.filename;
// Prevent directory traversal
const safePath = path.normalize(filename).replace(/^(\.\.(\/|\\|$))+/, '');
const filepath = path.join(__dirname, 'uploads', safePath);
// Check if file is in allowed directory
if (!filepath.startsWith(path.join(__dirname, 'uploads'))) {
return res.status(403).json({ error: 'Access denied' });
}
res.sendFile(filepath);
});Password Security
Best practices:
const bcrypt = require('bcrypt');
// Hash password with high cost factor
async function hashPassword(password) {
const salt = await bcrypt.genSalt(12); // 12 rounds
return await bcrypt.hash(password, salt);
}
// Password requirements
function validatePassword(password) {
const minLength = 8;
const maxLength = 128;
if (password.length < minLength || password.length > maxLength) {
return { valid: false, error: `Password must be ${minLength}-${maxLength} characters` };
}
const hasUpperCase = /[A-Z]/.test(password);
const hasLowerCase = /[a-z]/.test(password);
const hasNumbers = /\d/.test(password);
const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password);
if (!hasUpperCase || !hasLowerCase || !hasNumbers || !hasSpecialChar) {
return {
valid: false,
error: 'Password must contain uppercase, lowercase, number, and special character'
};
}
return { valid: true };
}
// Check against common passwords
const commonPasswords = new Set([
'password', '123456', 'qwerty', 'admin', 'letmein'
// ... add more
]);
if (commonPasswords.has(password.toLowerCase())) {
return res.status(400).json({ error: 'Password too common' });
}Password reset security:
const crypto = require('crypto');
// Generate secure reset token
function generateResetToken() {
return crypto.randomBytes(32).toString('hex');
}
// Hash token before storing
function hashToken(token) {
return crypto.createHash('sha256').update(token).digest('hex');
}
// Password reset request
app.post('/forgot-password', async (req, res) => {
const user = await User.findOne({ email: req.body.email });
if (!user) {
// Don't reveal if email exists
return res.json({ message: 'If email exists, reset link sent' });
}
const resetToken = generateResetToken();
const hashedToken = hashToken(resetToken);
user.resetPasswordToken = hashedToken;
user.resetPasswordExpires = Date.now() + 3600000; // 1 hour
await user.save();
// Send email with reset link containing unhashed token
await sendEmail({
to: user.email,
subject: 'Password Reset',
text: `Reset link: https://example.com/reset/${resetToken}`
});
res.json({ message: 'If email exists, reset link sent' });
});
// Reset password
app.post('/reset-password/:token', async (req, res) => {
const hashedToken = hashToken(req.params.token);
const user = await User.findOne({
resetPasswordToken: hashedToken,
resetPasswordExpires: { $gt: Date.now() }
});
if (!user) {
return res.status(400).json({ error: 'Invalid or expired token' });
}
// Validate new password
const validation = validatePassword(req.body.password);
if (!validation.valid) {
return res.status(400).json({ error: validation.error });
}
user.password = await hashPassword(req.body.password);
user.resetPasswordToken = undefined;
user.resetPasswordExpires = undefined;
await user.save();
res.json({ message: 'Password reset successful' });
});Environment Variables and Secrets
Never commit secrets:
// .env (add to .gitignore)
DATABASE_URL=mongodb://localhost:27017/myapp
JWT_SECRET=your-secret-key-here
CLOUDINARY_API_KEY=your-api-key
STRIPE_SECRET_KEY=sk_test_...
// Load with dotenv
require('dotenv').config();
const secret = process.env.JWT_SECRET;
// Validate required secrets
const requiredEnvVars = [
'DATABASE_URL',
'JWT_SECRET',
'JWT_REFRESH_SECRET'
];
for (const envVar of requiredEnvVars) {
if (!process.env[envVar]) {
console.error(`Missing required environment variable: ${envVar}`);
process.exit(1);
}
}Dependency Security
Keep dependencies updated:
# Check for vulnerabilities
npm audit
# Fix vulnerabilities
npm audit fix
# Check for outdated packages
npm outdated
# Update packages
npm update
# Use Snyk for continuous monitoring
npm install -g snyk
snyk test
snyk monitorLock dependency versions:
{
"dependencies": {
"express": "4.18.2", // Exact version, not ^4.18.2
"mongoose": "7.0.3"
}
}Security Logging
Log security events:
const winston = require('winston');
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.File({ filename: 'security.log', level: 'warn' })
]
});
// Log failed login attempts
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (!user || !await user.comparePassword(password)) {
logger.warn('Failed login attempt', {
email,
ip: req.ip,
userAgent: req.headers['user-agent'],
timestamp: new Date()
});
return res.status(401).json({ error: 'Invalid credentials' });
}
logger.info('Successful login', {
userId: user.id,
ip: req.ip,
timestamp: new Date()
});
// Generate token...
});
// Log suspicious activity
function detectSuspiciousActivity(req) {
// Multiple failed attempts
// Unusual access patterns
// Access from new location
// etc.
}Complete Security Middleware Stack
const express = require('express');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const mongoSanitize = require('express-mongo-sanitize');
const hpp = require('hpp');
const cors = require('cors');
const app = express();
// Security headers
app.use(helmet());
// CORS
app.use(cors({
origin: process.env.ALLOWED_ORIGINS?.split(',') || 'http://localhost:3000',
credentials: true
}));
// Rate limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100
});
app.use('/api', limiter);
// Body parsing
app.use(express.json({ limit: '10kb' })); // Limit body size
app.use(express.urlencoded({ extended: true, limit: '10kb' }));
// Data sanitization against NoSQL injection
app.use(mongoSanitize());
// Prevent HTTP Parameter Pollution
app.use(hpp());
// Force HTTPS
if (process.env.NODE_ENV === 'production') {
app.use((req, res, next) => {
if (req.header('x-forwarded-proto') !== 'https') {
res.redirect(`https://${req.header('host')}${req.url}`);
} else {
next();
}
});
}
// Your routes
app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);
// Error handler
app.use((err, req, res, next) => {
// Don't leak error details in production
const message = process.env.NODE_ENV === 'production'
? 'Something went wrong'
: err.message;
res.status(err.statusCode || 500).json({ error: message });
});
module.exports = app;Summary: Your Security Checklist
Injection Protection:
- Use parameterized queries
- Validate input types
- Sanitize user input
- Escape output
XSS Prevention:
- Escape all user content
- Use Content Security Policy
- Sanitize HTML input
- Use template engines that escape
CSRF Protection:
- Use CSRF tokens
- SameSite cookies
- Verify origin headers
Authentication:
- Hash passwords with bcrypt
- Use strong JWT secrets
- Implement rate limiting
- Account lockout after failures
- Secure password reset
General:
- Use HTTPS everywhere
- Security headers with Helmet
- Validate all input
- Keep dependencies updated
- Log security events
- Regular security audits
Practice Challenges
Secure these scenarios:
Challenge 1: API Security Audit Review and secure an existing API.
Challenge 2: Penetration Testing Try to break your own application.
Challenge 3: Security Headers Configure CSP and all security headers.
Challenge 4: Input Validation Build comprehensive validation for all endpoints.
Challenge 5: Security Monitoring Implement logging and alerting for suspicious activity.