What you'll build
A secure REST API with Express.js that implements web application security best practices.
You'll start with a vulnerable API and step by step transform it into a hardened API with:
- Security headers with Helmet.js
- Rate limiting to prevent brute force attacks
- Input validation with Zod
- SQL Injection prevention with parameterized queries
- XSS protection
- Secure CORS configuration
- Dependency auditing in CI/CD
When finished, you'll have a production-ready API with a verifiable security checklist.
Step 1: Create the base project
mkdir secure-api && cd secure-api
npm init -y
npm install express
Create server.js with a basic (vulnerable) API:
// server.js - VULNERABLE VERSION (DO NOT USE IN PRODUCTION)
const express = require('express');
const app = express();
app.use(express.json());
// Simulated database
const users = [
{ id: 1, name: 'Admin', email: 'admin@example.com', password: 'admin123' }
];
// Endpoint vulnerable to various attacks
app.get('/api/users', (req, res) => {
res.json(users); // Exposes passwords!
});
app.post('/api/login', (req, res) => {
const { email, password } = req.body;
// No input validation
// Vulnerable to injection
const user = users.find(u => u.email === email && u.password === password);
if (user) {
res.json({ message: 'Login successful', user }); // Exposes entire object
} else {
res.status(401).json({ message: 'Invalid credentials' });
}
});
app.listen(3000, () => console.log('Server at http://localhost:3000'));
Step 2: Add Helmet.js for security headers
Helmet automatically configures HTTP headers that protect against common vulnerabilities.
npm install helmet
Before (no security headers):
const app = express();
// No protection - Express default headers
After (with Helmet):
const express = require('express');
const helmet = require('helmet');
const app = express();
// Helmet adds 11+ security headers automatically
app.use(helmet());
// Headers that Helmet configures:
// - Content-Security-Policy: prevents XSS
// - X-Content-Type-Options: nosniff
// - X-Frame-Options: DENY (prevents clickjacking)
// - Strict-Transport-Security: forces HTTPS
// - X-XSS-Protection: browser XSS filter
Verify the headers:
curl -I http://localhost:3000/api/users
Step 3: Implement Rate Limiting
Prevents brute force attacks and DDoS by limiting requests per IP.
npm install express-rate-limit
Before (no limits):
// An attacker can make millions of requests
app.post('/api/login', (req, res) => { ... });
After (with rate limiting):
const rateLimit = require('express-rate-limit');
// Global limit: 100 requests per 15 minutes
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
message: { error: 'Too many requests. Try again in 15 minutes.' },
standardHeaders: true,
legacyHeaders: false,
});
// Strict limit for login: 5 attempts per 15 minutes
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
message: { error: 'Too many login attempts. Account temporarily locked.' },
skipSuccessfulRequests: true, // Don't count successful logins
});
app.use(globalLimiter);
app.post('/api/login', loginLimiter, (req, res) => { ... });
Step 4: Input validation with Zod
Never trust user data. Validate EVERYTHING.
npm install zod
Before (no validation):
app.post('/api/login', (req, res) => {
const { email, password } = req.body;
// If email is an object or array, it can cause errors
// If password is too long, it can cause DoS
});
After (with Zod):
const { z } = require('zod');
// Define validation schema
const loginSchema = z.object({
email: z.string()
.email('Invalid email')
.max(255, 'Email too long'),
password: z.string()
.min(8, 'Password must be at least 8 characters')
.max(128, 'Password too long'),
});
// Reusable validation middleware
const validate = (schema) => (req, res, next) => {
try {
schema.parse(req.body);
next();
} catch (error) {
res.status(400).json({
error: 'Invalid data',
details: error.errors.map(e => ({
field: e.path.join('.'),
message: e.message
}))
});
}
};
app.post('/api/login', validate(loginSchema), (req, res) => {
const { email, password } = req.body;
// Now we know email and password are valid strings
});
// Schema for creating user
const userSchema = z.object({
name: z.string().min(2).max(100).regex(/^[a-zA-Z\s]+$/, 'Letters only'),
email: z.string().email().max(255),
password: z.string()
.min(8)
.regex(/[A-Z]/, 'Must have uppercase')
.regex(/[0-9]/, 'Must have number')
.regex(/[^A-Za-z0-9]/, 'Must have special character'),
});
Step 5: SQL Injection Prevention
If you use SQL databases, ALWAYS use parameterized queries.
Before (VULNERABLE):
// NEVER do this - vulnerable to SQL injection
app.get('/api/users/:id', async (req, res) => {
const query = \`SELECT * FROM users WHERE id = \${req.params.id}\`;
// Attacker can send: 1; DROP TABLE users; --
const result = await db.query(query);
res.json(result);
});
After (SECURE) - with pg (PostgreSQL):
const { Pool } = require('pg');
const pool = new Pool();
app.get('/api/users/:id', async (req, res) => {
try {
// Parameterized query - driver escapes automatically
const result = await pool.query(
'SELECT id, name, email FROM users WHERE id = $1',
[req.params.id] // Separate parameter
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'User not found' });
}
res.json(result.rows[0]);
} catch (error) {
console.error('DB Error:', error);
res.status(500).json({ error: 'Internal error' });
}
});
// For INSERT also use parameters
app.post('/api/users', validate(userSchema), async (req, res) => {
const { name, email, password } = req.body;
const hashedPassword = await bcrypt.hash(password, 12);
const result = await pool.query(
'INSERT INTO users (name, email, password) VALUES ($1, $2, $3) RETURNING id, name, email',
[name, email, hashedPassword]
);
res.status(201).json(result.rows[0]);
});
With an ORM (Prisma) - also secure:
// Prisma uses parameterized queries internally
const user = await prisma.user.findUnique({
where: { id: parseInt(req.params.id) },
select: { id: true, name: true, email: true } // Don't select password
});
Step 6: XSS Protection
Cross-Site Scripting occurs when an attacker injects malicious scripts.
Before (VULNERABLE):
app.get('/api/comments', async (req, res) => {
const comments = await db.getComments();
res.json(comments);
// If a comment contains <script>alert('hacked')</script>
// and the frontend renders it with innerHTML, it executes
});
After (SECURE):
const createDOMPurify = require('dompurify');
const { JSDOM } = require('jsdom');
const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);
// Sanitize when saving
app.post('/api/comments', validate(commentSchema), async (req, res) => {
const sanitizedContent = DOMPurify.sanitize(req.body.content, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong'], // Only safe tags
ALLOWED_ATTR: [] // No attributes
});
await db.createComment({
content: sanitizedContent,
userId: req.user.id
});
res.status(201).json({ message: 'Comment created' });
});
// Alternative: escape HTML in frontend
// React does it automatically with JSX
// Vue does it with {{ }} (double braces)
Additional headers (already included in Helmet):
// Strict Content-Security-Policy
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"], // Only scripts from same origin
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
upgradeInsecureRequests: [],
},
}));
Step 7: Secure CORS Configuration
CORS controls which domains can access your API.
npm install cors
Before (INSECURE):
const cors = require('cors');
app.use(cors()); // Allows ALL origins - dangerous!
After (SECURE):
const cors = require('cors');
const allowedOrigins = [
'https://myapp.com',
'https://www.myapp.com',
process.env.NODE_ENV === 'development' && 'http://localhost:3000'
].filter(Boolean);
app.use(cors({
origin: (origin, callback) => {
// Allow requests without origin (mobile apps, Postman)
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('CORS not allowed'));
}
},
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true, // Allows cookies
maxAge: 86400 // Cache preflight for 24 hours
}));
Step 8: npm audit in CI/CD
Automatically detect vulnerabilities in dependencies.
Create .github/workflows/security.yml:
name: Security Audit
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: '0 0 * * 1' # Every Monday at midnight
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Run security audit
run: npm audit --audit-level=high
# Fails if there are high or critical vulnerabilities
- name: Run Snyk security scan
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
args: --severity-threshold=high
continue-on-error: true # Don't block, just alert
dependency-review:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- name: Dependency Review
uses: actions/dependency-review-action@v3
with:
fail-on-severity: high
Useful local commands:
# View vulnerabilities
npm audit
# Auto-fix
npm audit fix
# Fix including breaking changes
npm audit fix --force
# View only critical vulnerabilities
npm audit --audit-level=critical
Step 9: Complete secure code
// server.js - SECURE VERSION
require('dotenv').config();
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const rateLimit = require('express-rate-limit');
const { z } = require('zod');
const bcrypt = require('bcrypt');
const app = express();
// 1. Security headers
app.use(helmet());
// 2. CORS configured
const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(',') || [];
app.use(cors({
origin: allowedOrigins,
credentials: true
}));
// 3. Rate limiting
app.use(rateLimit({
windowMs: 15 * 60 * 1000,
max: 100
}));
app.use(express.json({ limit: '10kb' })); // Limit body size
// 4. Validation schemas
const loginSchema = z.object({
email: z.string().email().max(255),
password: z.string().min(8).max(128)
});
const validate = (schema) => (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ error: 'Invalid data' });
}
next();
};
// 5. Secure login
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
skipSuccessfulRequests: true
});
app.post('/api/login', loginLimiter, validate(loginSchema), async (req, res) => {
try {
const { email, password } = req.body;
// Find user (parameterized query)
const user = await db.query(
'SELECT id, email, password_hash FROM users WHERE email = $1',
[email]
);
if (!user.rows[0]) {
// Constant time to prevent timing attacks
await bcrypt.compare(password, '$2b$12$invalidhashtopreventtiming');
return res.status(401).json({ error: 'Invalid credentials' });
}
const valid = await bcrypt.compare(password, user.rows[0].password_hash);
if (!valid) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Response without sensitive data
res.json({
user: { id: user.rows[0].id, email: user.rows[0].email }
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ error: 'Internal error' });
}
});
// 6. Global error handling
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ error: 'Something went wrong' });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(\`Secure server on port \${PORT}\`);
});
Step 10: Verification checklist
Before going to production, verify each item:
| Category | Item | Command/Verification |
|---|---|---|
| Headers | Helmet active | curl -I localhost:3000 |
| Headers | CSP configured | Check Content-Security-Policy |
| Rate Limit | Global active | Make 101+ requests |
| Rate Limit | Strict login | Make 6+ failed logins |
| Validation | Zod on endpoints | Send invalid data |
| SQL | Parameterized queries | Review code |
| XSS | Sanitization active | Send <script>alert(1)</script> |
| CORS | Only allowed origins | Request from other domain |
| Deps | No vulnerabilities | npm audit |
| CI/CD | Automatic audit | Check GitHub Actions |
| Passwords | Hashed with bcrypt | Check DB |
| Errors | No stack traces | Force error in prod |
| HTTPS | Forced in prod | Verify redirect |
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| CORS blocked | Origin not in list | Add to allowedOrigins |
| Rate limit too strict | Too few requests allowed | Adjust max and windowMs |
| Zod rejects everything | Schema too strict | Review validations |
| npm audit fails | Vulnerable dependency | npm audit fix or update |
Next step
-> RAG with PDF Documents - Master Level