SecurityBackendNode.jsAPI

Backend Security: Beyond the Basics

Koushith

After a user reported they could see other users' data in our API responses, I spent a weekend doing a complete security audit of our backend. Here's everything I learned about keeping your Node.js APIs secure - from basic sanitization to some sneaky edge cases.

The Obvious Stuff (That People Still Mess Up)

Input Sanitization

Never trust user input. Ever. Here's 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 super readable
  • Error messages are actually helpful
  • Schema composition is powerful

SQL Injection Prevention

If you're using raw SQL queries (sometimes you have to), here's 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's a rate limiter I use that's more forgiving 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 until they're not. Here's my setup that's actually 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 a security nightmare. Here's 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's 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:

  1. Are all inputs validated and sanitized?
  2. Are API routes properly protected?
  3. Are file uploads restricted and scanned?
  4. Are error messages generic enough?
  5. Is rate limiting in place?
  6. Are tokens being handled securely?
  7. Is sensitive data being logged accidentally?

Final Thoughts

Security isn't a feature - it's a requirement. Start with these basics and keep learning. The threats keep evolving, and so should your security practices.

Got any security tips or horror stories? Let's chat on Twitter. I'm always looking to learn more about keeping our apps secure.