You’ve built an API for a shop, a SACCO or a client’s mobile app. It works on your laptop and you’re ready to deploy. Before you do, give Express API security one honest hour with the checks below. They come from real incidents we see in East African projects: leaked M-Pesa keys on GitHub, admin endpoints with no auth, and login pages that let bots try passwords forever.
Install the packages used in this guide:
npm install helmet express-rate-limit cors zod bcrypt
1. Set secure headers and body limits
Helmet sets sensible HTTP security headers in one line. Hiding X-Powered-By stops you advertising your stack, and a body limit stops someone sending you a 500 MB JSON payload.
const helmet = require('helmet');
app.disable('x-powered-by');
app.use(helmet());
app.use(express.json({ limit: '100kb' }));
2. Rate-limit everything, and login hardest
Without rate limits, a bot can try thousands of passwords or OTP codes a minute, or run up your SMS bill. Use a general limit for the API and a strict one for authentication and OTP routes.
const rateLimit = require('express-rate-limit');
app.use('/api/', rateLimit({ windowMs: 60 * 1000, limit: 100 }));
app.use('/api/auth/login', rateLimit({ windowMs: 15 * 60 * 1000, limit: 10 }));
If you run more than one server instance, store the counters in Redis so the limit is shared.
3. Validate every input
Never trust req.body, req.query or req.params. Define what valid data looks like and reject everything else. Zod makes this short:
const { z } = require('zod');
const Order = z.object({
phone: z.string().regex(/^2547\d{8}$/),
amount: z.number().int().min(1).max(150000),
});
app.post('/api/orders', (req, res) => {
const parsed = Order.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Invalid input' });
// use parsed.data, never req.body, from here on
});
4. Use parameterised queries
SQL injection is still one of the most common ways data leaks. Never build SQL by joining strings with user input. Every driver and ORM supports placeholders:
// Bad: string building lets an attacker rewrite the query
db.query("SELECT * FROM users WHERE email = '" + email + "'");
// Good: parameterised query
db.query('SELECT * FROM users WHERE email = $1', [email]);
5. Check authorisation on every record
This is the bug scanners miss and attackers love. A user is logged in, so your API returns /api/orders/1043, but order 1043 belongs to someone else. Always filter by the owner:
SELECT * FROM orders WHERE id = $1 AND customer_id = $2
Do the same for updates and deletes, and keep admin routes behind a separate role check.
6. Store passwords properly
Hash passwords with bcrypt or Argon2, never with MD5 or SHA-1 and never in plain text. Use short-lived tokens, and if you use cookies, set them httpOnly, secure and sameSite.
const bcrypt = require('bcrypt');
const hash = await bcrypt.hash(password, 12);
const ok = await bcrypt.compare(attempt, hash);
7. Keep secrets out of your code
M-Pesa consumer keys, database passwords and JWT secrets belong in environment variables, not in your repository. Add .env to .gitignore before your first commit. If a secret was ever pushed, even to a private repo, treat it as leaked: rotate it, then remove it from history.
8. Lock down CORS
origin: '*' lets any website call your API from a visitor’s browser. List the front-ends you actually own:
const cors = require('cors');
app.use(cors({ origin: ['https://app.example.co.ke'], credentials: true }));
9. Do not leak errors
Stack traces tell attackers your file paths, library versions and queries. Log the details on the server and return a plain message to the client. Also make sure NODE_ENV=production is set when you deploy.
app.use((err, req, res, next) => {
console.error(err); // full details go to your logs
res.status(err.status || 500).json({ error: 'Something went wrong' });
});
10. Patch dependencies and watch your logs
Most Node apps have hundreds of dependencies. Check them before every release and turn on GitHub’s Dependabot alerts.
npm audit --omit=dev
npm outdated
Log logins, failed logins, payment callbacks and permission errors, with time and IP, and look at them weekly. A sudden spike in failed logins is often the first sign of an attack.
Bonus: HTTPS everywhere
Serve the API only over HTTPS. Free certificates from Let’s Encrypt or your host’s AutoSSL are fine. Never send tokens, passwords or payment data over plain HTTP, even “just for testing”.
A quick pre-launch list
- Helmet on, body limit set.
- Rate limits on API, login and OTP.
- Every route validates input.
- No string-built SQL.
- Ownership checked on every record.
- Passwords hashed with bcrypt or Argon2.
- No secrets in Git.
- CORS lists real origins only.
- Errors are generic in production.
npm auditclean, logs reviewed.
None of this makes an API unbreakable, but it closes the doors attackers try first. If you handle payments or personal data, get an independent security test before launch too. For the bigger picture, the OWASP API Security Top 10 is worth a read, and when you’re ready to ship, see deploying Node.js on cPanel or on Vercel.




