Backend Security: Beyond the Basics
On this page
After one user told me they could see other users' data in our API, I spent the whole weekend checking the entire backend security. Here is everything I learned about keeping Node.js APIs secure. From basic cleaning to some tricky edge cases.
The Obvious Stuff (That People Still Mess Up)
Input Sanitization
Never trust user input. Simply don't. Here is how I handle it:
// 🚫 The naive way
app.post('/api/users', (req, res) => {
const { name, email } = req.body;
// Direct database query - yikes!
db.users.create({ name, email });
});
// ✅ The proper way
import { z } from 'zod';
const userSchema = z.object({
name: z.string().min(2).max(50),
email: z.string().email(),
role: z.enum(['user', 'admin']).default('user'),
});
app.post('/api/users', async (req, res) => {
try {
const validatedData = userSchema.parse(req.body);
await db.users.create(validatedData);
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
error: 'Invalid input',
details: error.errors,
});
}
// Handle other errors
}
});
I use Zod for validation because:
- Type inference works great with TypeScript
- Validation rules are very readable
- Error messages are actually helpful
- Schema composition is powerful
SQL Injection Prevention
If you are using raw SQL queries (sometimes you require to), here is how to do it safely:
// 🚫 SQL Injection heaven
const query = `SELECT * FROM users WHERE email = '${userInput}'`;
// ✅ Using prepared statements
const query = 'SELECT * FROM users WHERE email = $1';
const values = [userInput];
await pool.query(query, values);
// Even better - use Prisma or TypeORM
const user = await prisma.user.findUnique({
where: { email: userInput },
});
The Sneaky Security Issues
Rate Limiting
Here is a rate limiter I use. It is easy for real users but tough on bots:
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
// Different limits for different endpoints
const authLimiter = rateLimit({
store: new RedisStore({
client: redis,
prefix: 'rl:auth:',
}),
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts
message: 'Too many login attempts. Please try again later.',
});
const apiLimiter = rateLimit({
store: new RedisStore({
client: redis,
prefix: 'rl:api:',
}),
windowMs: 60 * 1000, // 1 minute
max: 100, // 100 requests
// Custom handler for API responses
handler: (req, res) => {
res.status(429).json({
error: 'Too many requests',
retryAfter: res.getHeader('Retry-After'),
});
},
});
// Apply to routes
app.post('/api/auth/login', authLimiter, loginHandler);
app.use('/api', apiLimiter);
JWT Security
JWT tokens are great, but sometimes risky. Here is my setup that is properly secure:
import jwt from 'jsonwebtoken';
// 🚫 Basic JWT implementation
const token = jwt.sign({ userId: user.id }, 'secret');
// ✅ Better JWT handling
const generateTokens = (user) => {
// Access token - short lived
const accessToken = jwt.sign({ userId: user.id }, process.env.JWT_ACCESS_SECRET, { expiresIn: '15m' });
// Refresh token - longer lived, but more limited
const refreshToken = jwt.sign({ userId: user.id, version: user.tokenVersion }, process.env.JWT_REFRESH_SECRET, {
expiresIn: '7d',
});
return { accessToken, refreshToken };
};
// Invalidate all tokens when needed
const invalidateUserTokens = async (userId) => {
await prisma.user.update({
where: { id: userId },
data: { tokenVersion: { increment: 1 } },
});
};
File Upload Security
File uploads are always a headache for security. Here is how I handle them:
import crypto from 'crypto';
import path from 'path';
import { fileTypeFromBuffer } from 'file-type';
const uploadMiddleware = async (req, res, next) => {
try {
if (!req.files?.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
const file = req.files.file;
const buffer = await file.data;
// Check real file type, don't trust extension
const fileType = await fileTypeFromBuffer(buffer);
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
if (!fileType || !allowedTypes.includes(fileType.mime)) {
return res.status(400).json({ error: 'Invalid file type' });
}
// Generate safe filename
const fileName = crypto.randomBytes(32).toString('hex') + path.extname(file.name);
// Store file metadata in your DB
const fileDoc = await prisma.upload.create({
data: {
fileName,
originalName: file.name,
mimeType: fileType.mime,
size: buffer.length,
userId: req.user.id,
},
});
// Attach to request for next middleware
req.uploadedFile = fileDoc;
next();
} catch (error) {
next(error);
}
};
Environment Variables
Here is a trick I use to make sure all required env vars are present:
// config/env.ts
import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
REDIS_URL: z.string().url().optional(),
AWS_BUCKET: z.string(),
AWS_REGION: z.string(),
AWS_ACCESS_KEY: z.string(),
AWS_SECRET_KEY: z.string(),
});
// Validate on startup
const env = envSchema.parse(process.env);
export default env;
The Security Checklist
Before deploying, I always check:
- Are all inputs validated and cleaned?
- Are API routes properly protected?
- Are file uploads restricted and scanned?
- Are error messages simple enough?
- Is rate limiting working?
- Are tokens being handled safely?
- Is sensitive data being logged by mistake?
Final Thoughts
Security is not a feature. It is a mandatory thing. Start with these basics and keep learning. Threats keep changing, so your security practices should also change.
Got any security tips or scary stories? Message me on Twitter. Always ready to learn more about keeping apps secure.
Used LLMs to correct grammar, typos etc 🤖