๐Ÿ”

Security Hardening

๐Ÿ‘จโ€๐Ÿณ Chefโฑ๏ธ 45 minutes

๐Ÿ“‹ Suggested prerequisites

  • โ€ขNode.js installed
  • โ€ขBasic Express.js knowledge
  • โ€ขnpm or pnpm

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:

CategoryItemCommand/Verification
HeadersHelmet activecurl -I localhost:3000
HeadersCSP configuredCheck Content-Security-Policy
Rate LimitGlobal activeMake 101+ requests
Rate LimitStrict loginMake 6+ failed logins
ValidationZod on endpointsSend invalid data
SQLParameterized queriesReview code
XSSSanitization activeSend <script>alert(1)</script>
CORSOnly allowed originsRequest from other domain
DepsNo vulnerabilitiesnpm audit
CI/CDAutomatic auditCheck GitHub Actions
PasswordsHashed with bcryptCheck DB
ErrorsNo stack tracesForce error in prod
HTTPSForced in prodVerify redirect

Troubleshooting

ProblemCauseSolution
CORS blockedOrigin not in listAdd to allowedOrigins
Rate limit too strictToo few requests allowedAdjust max and windowMs
Zod rejects everythingSchema too strictReview validations
npm audit failsVulnerable dependencynpm audit fix or update

Next step

-> RAG with PDF Documents - Master Level