Securing Your API
Building a Secure API
Authentication keeps unauthorised users out. But a fully authenticated API can still be vulnerable to attacks if it is not hardened against other threats. In this lesson we cover the practical security layers every production Express API needs.
CORS — Cross-Origin Resource Sharing
CORS (Cross-Origin Resource Sharing) is a browser security policy that prevents JavaScript on one domain from making requests to a different domain — unless the server explicitly allows it.
If your frontend is at https://careerex.com and your API is at https://api.careerex.com, the browser will block requests unless your API sends the correct CORS headers.
Install the cors package:
npm install cors
Basic CORS Setup
const cors = require('cors');
// Allow all origins — fine for development, dangerous in production
app.use(cors());
Production CORS Configuration
const allowedOrigins = [
'https://careerex.com',
'https://www.careerex.com',
process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : null,
].filter(Boolean);
app.use(cors({
origin: (origin, callback) => {
// Allow requests with no origin (e.g. Postman, mobile apps)
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true, // Allow cookies to be sent
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
}));
Rate Limiting
Without rate limiting, a malicious actor can flood your API with thousands of requests per second — either to find vulnerabilities (fuzzing) or to take it down (a denial of service attack). Rate limiting caps how many requests a single IP address can make in a time window.
Install express-rate-limit:
npm install express-rate-limit
const rateLimit = require('express-rate-limit');
// General API rate limit
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window per IP
message: { error: 'Too many requests, please try again in 15 minutes.' },
standardHeaders: true,
legacyHeaders: false,
});
// Stricter limit for authentication endpoints to slow down brute-force attacks
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10, // Only 10 login attempts per 15 minutes
message: { error: 'Too many login attempts, please try again later.' },
});
app.use('/api', apiLimiter);
app.use('/api/auth', authLimiter);
Helmet — Securing HTTP Headers
Helmet is a middleware that sets various HTTP response headers to protect your API from common web vulnerabilities. It takes about one line to add but covers a lot of ground.
npm install helmet
const helmet = require('helmet');
// Adds over a dozen security headers automatically
app.use(helmet());
Some of the headers Helmet sets:
| Header | Purpose |
|---|---|
X-Content-Type-Options | Prevents browsers from MIME-sniffing responses |
X-Frame-Options | Prevents your API from being embedded in an iframe |
Strict-Transport-Security | Forces HTTPS connections |
X-XSS-Protection | Enables browser XSS (Cross-Site Scripting) filtering |
Input Sanitisation
Never trust input from users. Attackers inject malicious data through request bodies, query strings, and headers. Sanitise all input before using it.
Install express-mongo-sanitize:
npm install express-mongo-sanitize
const mongoSanitize = require('express-mongo-sanitize');
// Strips MongoDB operators ($, .) from user input — prevents NoSQL injection
app.use(mongoSanitize());
Without this, an attacker could send:
{
"email": { "$gt": "" },
"password": "anything"
}
This query would match the first user in the database regardless of password — a NoSQL injection attack.
For additional sanitisation, use the xss-clean package to strip HTML tags and prevent stored XSS (Cross-Site Scripting):
npm install xss-clean
const xss = require('xss-clean');
app.use(xss());
Environment Variables for Secrets
Hardcoding secrets in your source code is one of the most common and costly security mistakes. Secrets committed to Git repositories are frequently discovered by attackers scanning public repos.
Never hardcode:
- Database connection strings
- JWT secret keys
- API keys (Paystack secret key, Cloudinary credentials, etc.)
- SMTP (Simple Mail Transfer Protocol) passwords
Use environment variables instead:
// Wrong — hardcoded secret
const token = jwt.sign(payload, 'mysecretkey123');
// Correct — from environment variable
const token = jwt.sign(payload, process.env.JWT_SECRET);
Managing Secrets with dotenv
npm install dotenv
// At the very top of your server.js file
require('dotenv').config();
Your .env file:
MONGO_URI=mongodb+srv://...
JWT_SECRET=a64characterrandomstringgeneratedwithcryptorandomBytes
PAYSTACK_SECRET_KEY=sk_live_...
PORT=5000
Always add .env to your .gitignore file. Provide a .env.example file with placeholder values for other developers:
# .env.example — commit this file, not .env
MONGO_URI=your_mongodb_connection_string
JWT_SECRET=your_jwt_secret_here
PAYSTACK_SECRET_KEY=your_paystack_key_here
PORT=5000
Putting It All Together
A secure Express server setup:
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const mongoSanitize = require('express-mongo-sanitize');
const xss = require('xss-clean');
const connectDB = require('./db');
const app = express();
// Security middleware — apply before routes
app.use(helmet());
app.use(cors({ origin: process.env.ALLOWED_ORIGIN, credentials: true }));
app.use(express.json({ limit: '10kb' })); // Limit request body size to prevent payload attacks
app.use(mongoSanitize());
app.use(xss());
// Rate limiting
const limiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 100 });
app.use('/api', limiter);
// Routes
app.use('/api/auth', require('./routes/auth'));
app.use('/api/users', require('./routes/users'));
connectDB();
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => console.log('Server running on port ' + PORT));
Practice Exercise
Harden your existing Express API:
- Install cors, helmet, express-rate-limit, and express-mongo-sanitize
- Configure CORS to only allow your frontend's domain (and localhost for development)
- Apply a rate limit of 100 requests per 15 minutes on all API routes and 10 requests per 15 minutes on auth routes
- Move all secrets (database URL, JWT secret) to a
.envfile and add.envto.gitignore - Create a
.env.examplefile with placeholder values and commit it - Test that sending a MongoDB operator in the request body (e.g.
{ "email": { "$gt": "" } }) is safely stripped by express-mongo-sanitize
Try it yourself
Key Takeaways
- Configure CORS to only allow your specific frontend domains — never use wildcard origins in production.
- Apply rate limiting to all API routes, with a much stricter limit on authentication endpoints to prevent brute-force attacks.
- Helmet adds important HTTP security headers in a single line — include it in every Express application.
- Use express-mongo-sanitize to strip MongoDB operators from user input, preventing NoSQL injection attacks.
- Store all secrets in environment variables, add .env to .gitignore, and provide a .env.example with placeholder values.
Quick Quiz
1.What does CORS (Cross-Origin Resource Sharing) protect against?
2.Why should you apply a stricter rate limit to authentication endpoints than general API routes?
3.What security risk does express-mongo-sanitize address?
4.What should you do with secrets like JWT secret keys and database passwords in a Node.js project?
Ready to go further?
CareerEx gives you structured 12-week training, live classes every Saturday and Sunday, real tutor feedback, and a certificate. Join the next cohort.
Join CareerEx