Skip to main content
Back to Blog
SecurityJavaScriptOWASPWeb DevelopmentReactNode.jsBest Practices

Secure-by-Design Web Apps: Implementing OWASP Top 10 in a Modern JavaScript Stack

Learn how to build secure web applications from the ground up by implementing OWASP Top 10 security controls in modern JavaScript frameworks like React, Next.js, and Node.js with practical code examples.

April 18, 202618 min readNiraj Kumar

Introduction

Security breaches cost companies an average of $4.45 million per incident in 2024, yet many developers still treat security as an afterthought. The "secure-by-design" approach flips this paradigm by baking security into every layer of your application from day one.

The OWASP Top 10 provides a consensus-driven framework for the most critical web application security risks. In this comprehensive guide, we'll walk through implementing each of these controls in a modern JavaScript stack using React, Next.js, and Node.js with Express.

Whether you're building your first production app or hardening an existing system, this guide will give you practical, copy-paste-ready solutions that you can implement today.

Understanding the OWASP Top 10 (2021 Edition)

Before diving into implementation, let's quickly overview the current OWASP Top 10:

  1. Broken Access Control - Users can act outside their intended permissions
  2. Cryptographic Failures - Inadequate protection of sensitive data
  3. Injection - Untrusted data sent to interpreters
  4. Insecure Design - Missing or ineffective security controls
  5. Security Misconfiguration - Improper security settings
  6. Vulnerable and Outdated Components - Using components with known vulnerabilities
  7. Identification and Authentication Failures - Weak authentication mechanisms
  8. Software and Data Integrity Failures - Unverified code and data
  9. Security Logging and Monitoring Failures - Insufficient logging
  10. Server-Side Request Forgery (SSRF) - Fetching remote resources without validation

Now let's implement defenses for each.

1. Broken Access Control

The Problem

Access control enforces policies so users can't act outside their permissions. Without proper controls, attackers can access unauthorized functionality or data.

Implementation: Role-Based Access Control (RBAC)

Backend Middleware (Node.js/Express)

// middleware/auth.js
import jwt from 'jsonwebtoken';

export const authenticate = (req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1];
  
  if (!token) {
    return res.status(401).json({ error: 'No token provided' });
  }

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded;
    next();
  } catch (error) {
    return res.status(401).json({ error: 'Invalid token' });
  }
};

export const authorize = (...allowedRoles) => {
  return (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({ error: 'Not authenticated' });
    }

    if (!allowedRoles.includes(req.user.role)) {
      return res.status(403).json({ 
        error: 'Insufficient permissions' 
      });
    }

    next();
  };
};

// Usage in routes
app.delete('/api/users/:id', 
  authenticate, 
  authorize('admin'), 
  deleteUser
);

Frontend Implementation (React)

// hooks/useAuth.js
import { createContext, useContext, useState, useEffect } from 'react';

const AuthContext = createContext(null);

export const AuthProvider = ({ children }) => {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // Verify token on mount
    const token = localStorage.getItem('token');
    if (token) {
      verifyToken(token).then(setUser).finally(() => setLoading(false));
    } else {
      setLoading(false);
    }
  }, []);

  const hasPermission = (requiredRole) => {
    if (!user) return false;
    const roleHierarchy = { user: 1, moderator: 2, admin: 3 };
    return roleHierarchy[user.role] >= roleHierarchy[requiredRole];
  };

  return (
    <AuthContext.Provider value={{ user, hasPermission, loading }}>
      {children}
    </AuthContext.Provider>
  );
};

export const useAuth = () => useContext(AuthContext);

// Protected Component
const AdminPanel = () => {
  const { hasPermission } = useAuth();

  if (!hasPermission('admin')) {
    return <div>Access Denied</div>;
  }

  return <div>Admin Content</div>;
};

Common Mistakes

  • Client-side only checks - Always validate on the server
  • Predictable IDs - Use UUIDs instead of sequential integers
  • Missing ownership checks - Verify the user owns the resource they're accessing

2. Cryptographic Failures

The Problem

Sensitive data like passwords, credit cards, and personal information must be protected in transit and at rest.

Implementation: Encryption Best Practices

Password Hashing with bcrypt

// utils/password.js
import bcrypt from 'bcrypt';

const SALT_ROUNDS = 12;

export const hashPassword = async (password) => {
  // Never store plain text passwords
  return await bcrypt.hash(password, SALT_ROUNDS);
};

export const verifyPassword = async (password, hash) => {
  return await bcrypt.compare(password, hash);
};

// User registration
app.post('/api/register', async (req, res) => {
  const { email, password } = req.body;
  
  // Validate password strength
  if (password.length < 12) {
    return res.status(400).json({ 
      error: 'Password must be at least 12 characters' 
    });
  }

  const hashedPassword = await hashPassword(password);
  
  await db.users.create({
    email,
    password: hashedPassword
  });

  res.status(201).json({ message: 'User created' });
});

Encrypting Sensitive Data at Rest

// utils/encryption.js
import crypto from 'crypto';

const ALGORITHM = 'aes-256-gcm';
const KEY = Buffer.from(process.env.ENCRYPTION_KEY, 'hex'); // 32 bytes

export const encrypt = (text) => {
  const iv = crypto.randomBytes(16);
  const cipher = crypto.createCipheriv(ALGORITHM, KEY, iv);
  
  let encrypted = cipher.update(text, 'utf8', 'hex');
  encrypted += cipher.final('hex');
  
  const authTag = cipher.getAuthTag();
  
  return {
    encrypted,
    iv: iv.toString('hex'),
    authTag: authTag.toString('hex')
  };
};

export const decrypt = (encryptedData) => {
  const decipher = crypto.createDecipheriv(
    ALGORITHM,
    KEY,
    Buffer.from(encryptedData.iv, 'hex')
  );
  
  decipher.setAuthTag(Buffer.from(encryptedData.authTag, 'hex'));
  
  let decrypted = decipher.update(encryptedData.encrypted, 'hex', 'utf8');
  decrypted += decipher.final('utf8');
  
  return decrypted;
};

// Store sensitive data
const sensitiveData = encrypt(req.body.creditCard);
await db.payments.create(sensitiveData);

HTTPS Enforcement (Next.js)

// middleware.js
import { NextResponse } from 'next/server';

export function middleware(request) {
  // Force HTTPS in production
  if (
    process.env.NODE_ENV === 'production' &&
    request.headers.get('x-forwarded-proto') !== 'https'
  ) {
    return NextResponse.redirect(
      `https://${request.headers.get('host')}${request.nextUrl.pathname}`,
      301
    );
  }

  // Set security headers
  const response = NextResponse.next();
  response.headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
  
  return response;
}

3. Injection Attacks

The Problem

Injection flaws occur when untrusted data is sent to an interpreter as part of a command or query. SQL, NoSQL, OS, and LDAP injection are common variants.

Implementation: Input Validation & Parameterized Queries

SQL Injection Prevention

// ❌ VULNERABLE CODE - Never do this!
app.get('/api/users', async (req, res) => {
  const userId = req.query.id;
  const query = `SELECT * FROM users WHERE id = ${userId}`;
  const result = await db.query(query); // DANGEROUS!
});

// ✅ SECURE CODE - Use parameterized queries
app.get('/api/users', async (req, res) => {
  const userId = req.query.id;
  
  // With PostgreSQL (pg library)
  const result = await db.query(
    'SELECT * FROM users WHERE id = $1',
    [userId]
  );
  
  res.json(result.rows);
});

NoSQL Injection Prevention (MongoDB)

import { body, validationResult } from 'express-validator';

// ❌ VULNERABLE
app.post('/api/login', async (req, res) => {
  const user = await User.findOne({
    username: req.body.username,
    password: req.body.password
  });
  // Attack: { "username": { "$gt": "" }, "password": { "$gt": "" } }
});

// ✅ SECURE - Validate and sanitize
app.post('/api/login', [
  body('username').isString().trim().escape(),
  body('password').isString()
], async (req, res) => {
  const errors = validationResult(req);
  if (!errors.isEmpty()) {
    return res.status(400).json({ errors: errors.array() });
  }

  const { username, password } = req.body;
  
  const user = await User.findOne({ 
    username: String(username) 
  });
  
  if (!user || !(await verifyPassword(password, user.password))) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }
  
  res.json({ token: generateToken(user) });
});

Command Injection Prevention

// ❌ VULNERABLE - User input in shell commands
const { exec } = require('child_process');
app.get('/api/ping', (req, res) => {
  exec(`ping -c 4 ${req.query.host}`, (error, stdout) => {
    res.send(stdout);
  });
  // Attack: ?host=google.com;rm -rf /
});

// ✅ SECURE - Use libraries instead of shell commands
import ping from 'ping';

app.get('/api/ping', async (req, res) => {
  const host = req.query.host;
  
  // Validate input
  if (!/^[a-zA-Z0-9.-]+$/.test(host)) {
    return res.status(400).json({ error: 'Invalid host' });
  }
  
  const result = await ping.promise.probe(host);
  res.json(result);
});

XSS Prevention in React

// React automatically escapes output, but be careful with dangerouslySetInnerHTML

// ❌ VULNERABLE
const UserComment = ({ comment }) => {
  return <div dangerouslySetInnerHTML={{ __html: comment }} />;
};

// ✅ SECURE - Sanitize HTML
import DOMPurify from 'isomorphic-dompurify';

const UserComment = ({ comment }) => {
  const sanitized = DOMPurify.sanitize(comment, {
    ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a'],
    ALLOWED_ATTR: ['href']
  });
  
  return <div dangerouslySetInnerHTML={{ __html: sanitized }} />;
};

4. Insecure Design

The Problem

Insecure design represents missing or ineffective security controls at the design phase, not implementation bugs.

Implementation: Security Design Patterns

Rate Limiting to Prevent Brute Force

import rateLimit from 'express-rate-limit';

// Global rate limiter
const globalLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // Limit each IP to 100 requests per windowMs
  message: 'Too many requests from this IP'
});

// Strict limiter for auth endpoints
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5, // Only 5 login attempts per 15 minutes
  skipSuccessfulRequests: true
});

app.use('/api/', globalLimiter);
app.post('/api/login', authLimiter, loginHandler);

Implementing Account Lockout

// models/User.js
const userSchema = new Schema({
  email: String,
  password: String,
  loginAttempts: { type: Number, default: 0 },
  lockUntil: Date
});

userSchema.methods.isLocked = function() {
  return this.lockUntil && this.lockUntil > Date.now();
};

userSchema.methods.incrementLoginAttempts = async function() {
  // Reset attempts if lock has expired
  if (this.lockUntil && this.lockUntil < Date.now()) {
    return this.updateOne({
      $set: { loginAttempts: 1 },
      $unset: { lockUntil: 1 }
    });
  }

  const updates = { $inc: { loginAttempts: 1 } };
  
  // Lock account after 5 failed attempts
  if (this.loginAttempts + 1 >= 5) {
    updates.$set = { 
      lockUntil: Date.now() + 2 * 60 * 60 * 1000 // 2 hours
    };
  }

  return this.updateOne(updates);
};

// Login handler
app.post('/api/login', async (req, res) => {
  const user = await User.findOne({ email: req.body.email });
  
  if (!user) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  if (user.isLocked()) {
    return res.status(423).json({ 
      error: 'Account locked. Try again later.' 
    });
  }

  const isValid = await verifyPassword(req.body.password, user.password);
  
  if (!isValid) {
    await user.incrementLoginAttempts();
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  // Reset attempts on successful login
  await user.updateOne({
    $set: { loginAttempts: 0 },
    $unset: { lockUntil: 1 }
  });

  res.json({ token: generateToken(user) });
});

5. Security Misconfiguration

The Problem

Security misconfiguration includes improper HTTP headers, verbose error messages, default credentials, and unnecessary features enabled.

Implementation: Secure Configuration

Security Headers with Helmet

import helmet from 'helmet';

app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      styleSrc: ["'self'", "'unsafe-inline'"],
      scriptSrc: ["'self'"],
      imgSrc: ["'self'", "data:", "https:"],
      connectSrc: ["'self'"],
      fontSrc: ["'self'"],
      objectSrc: ["'none'"],
      mediaSrc: ["'self'"],
      frameSrc: ["'none'"]
    }
  },
  hsts: {
    maxAge: 31536000,
    includeSubDomains: true,
    preload: true
  },
  noSniff: true,
  xssFilter: true,
  referrerPolicy: { policy: 'strict-origin-when-cross-origin' }
}));

Next.js Security Headers

// next.config.js
module.exports = {
  async headers() {
    return [
      {
        source: '/:path*',
        headers: [
          {
            key: 'X-DNS-Prefetch-Control',
            value: 'on'
          },
          {
            key: 'X-Frame-Options',
            value: 'SAMEORIGIN'
          },
          {
            key: 'X-Content-Type-Options',
            value: 'nosniff'
          },
          {
            key: 'Referrer-Policy',
            value: 'strict-origin-when-cross-origin'
          },
          {
            key: 'Permissions-Policy',
            value: 'camera=(), microphone=(), geolocation=()'
          }
        ]
      }
    ];
  }
};

Environment Variables Security

// .env.example (commit this)
NODE_ENV=development
DATABASE_URL=
JWT_SECRET=
ENCRYPTION_KEY=

// .env (never commit this!)
NODE_ENV=production
DATABASE_URL=postgresql://user:pass@localhost/db
JWT_SECRET=your-256-bit-secret
ENCRYPTION_KEY=your-hex-encryption-key

// config/env.js - Validate required vars
const requiredEnvVars = [
  'DATABASE_URL',
  'JWT_SECRET',
  'ENCRYPTION_KEY'
];

requiredEnvVars.forEach((varName) => {
  if (!process.env[varName]) {
    throw new Error(`Missing required environment variable: ${varName}`);
  }
});

// Never expose secrets in error messages
app.use((err, req, res, next) => {
  console.error(err.stack); // Log full error server-side
  
  res.status(500).json({
    error: process.env.NODE_ENV === 'production' 
      ? 'Internal server error' 
      : err.message
  });
});

6. Vulnerable and Outdated Components

The Problem

Using components with known vulnerabilities can expose your entire application to attack.

Implementation: Dependency Management

Automated Dependency Scanning

// package.json
{
  "scripts": {
    "audit": "npm audit",
    "audit:fix": "npm audit fix",
    "outdated": "npm outdated",
    "update:check": "npx npm-check-updates"
  },
  "devDependencies": {
    "npm-check-updates": "^16.14.0"
  }
}

GitHub Actions for Security Scanning

# .github/workflows/security.yml
name: Security Audit

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  schedule:
    - cron: '0 0 * * 0' # Weekly

jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '20'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Run security audit
        run: npm audit --audit-level=moderate
      
      - name: Check for outdated packages
        run: npm outdated || true

Dependency Version Pinning

// package.json - Use exact versions in production
{
  "dependencies": {
    "express": "4.18.2",     // Exact version
    "mongoose": "^7.0.0"     // OK for minor updates
  },
  "devDependencies": {
    "jest": "~29.5.0"        // OK for patch updates
  }
}

// Use package-lock.json or yarn.lock
// Commit these files to ensure consistent installs

7. Identification and Authentication Failures

The Problem

Authentication-related vulnerabilities allow attackers to assume other users' identities.

Implementation: Robust Authentication

Implementing JWT with Refresh Tokens

// utils/tokens.js
import jwt from 'jsonwebtoken';

export const generateAccessToken = (user) => {
  return jwt.sign(
    { 
      id: user.id, 
      email: user.email, 
      role: user.role 
    },
    process.env.JWT_SECRET,
    { expiresIn: '15m' } // Short-lived
  );
};

export const generateRefreshToken = (user) => {
  return jwt.sign(
    { id: user.id },
    process.env.REFRESH_TOKEN_SECRET,
    { expiresIn: '7d' }
  );
};

// Login endpoint
app.post('/api/login', async (req, res) => {
  const { email, password } = req.body;
  
  const user = await User.findOne({ email });
  if (!user || !(await verifyPassword(password, user.password))) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  const accessToken = generateAccessToken(user);
  const refreshToken = generateRefreshToken(user);
  
  // Store refresh token (hashed)
  await db.refreshTokens.create({
    userId: user.id,
    token: await hashPassword(refreshToken),
    expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
  });

  res.json({ 
    accessToken, 
    refreshToken 
  });
});

// Refresh endpoint
app.post('/api/refresh', async (req, res) => {
  const { refreshToken } = req.body;
  
  try {
    const decoded = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET);
    
    // Verify token exists in database
    const storedTokens = await db.refreshTokens.find({ 
      userId: decoded.id 
    });
    
    const isValid = await Promise.any(
      storedTokens.map(t => verifyPassword(refreshToken, t.token))
    );
    
    if (!isValid) {
      return res.status(401).json({ error: 'Invalid refresh token' });
    }

    const user = await User.findById(decoded.id);
    const newAccessToken = generateAccessToken(user);
    
    res.json({ accessToken: newAccessToken });
  } catch (error) {
    res.status(401).json({ error: 'Invalid refresh token' });
  }
});

Multi-Factor Authentication (MFA)

import speakeasy from 'speakeasy';
import QRCode from 'qrcode';

// Enable MFA
app.post('/api/mfa/setup', authenticate, async (req, res) => {
  const secret = speakeasy.generateSecret({
    name: `YourApp (${req.user.email})`
  });

  // Store secret (encrypted!)
  await User.findByIdAndUpdate(req.user.id, {
    mfaSecret: encrypt(secret.base32)
  });

  // Generate QR code
  const qrCode = await QRCode.toDataURL(secret.otpauth_url);
  
  res.json({ qrCode, secret: secret.base32 });
});

// Verify MFA
app.post('/api/mfa/verify', authenticate, async (req, res) => {
  const { token } = req.body;
  const user = await User.findById(req.user.id);
  
  const secret = decrypt(user.mfaSecret);
  
  const verified = speakeasy.totp.verify({
    secret,
    encoding: 'base32',
    token,
    window: 2 // Allow 2 time steps of tolerance
  });

  if (verified) {
    await User.findByIdAndUpdate(user.id, { mfaEnabled: true });
    res.json({ success: true });
  } else {
    res.status(401).json({ error: 'Invalid MFA code' });
  }
});

8. Software and Data Integrity Failures

The Problem

Code and infrastructure that don't protect against integrity violations can lead to unauthorized access or malicious code execution.

Implementation: Integrity Checks

Subresource Integrity (SRI) for CDN Assets

// Next.js _document.js
import { Html, Head, Main, NextScript } from 'next/document';

export default function Document() {
  return (
    <Html>
      <Head>
        <link
          rel="stylesheet"
          href="https://cdn.example.com/styles.css"
          integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
          crossOrigin="anonymous"
        />
        <script
          src="https://cdn.example.com/script.js"
          integrity="sha384-abc123..."
          crossOrigin="anonymous"
        />
      </Head>
      <body>
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}

Digital Signatures for API Responses

import crypto from 'crypto';

// Generate signature
const signData = (data, secret) => {
  return crypto
    .createHmac('sha256', secret)
    .update(JSON.stringify(data))
    .digest('hex');
};

// API endpoint
app.get('/api/critical-data', authenticate, async (req, res) => {
  const data = await getCriticalData();
  const signature = signData(data, process.env.SIGNING_SECRET);
  
  res.json({
    data,
    signature,
    timestamp: Date.now()
  });
});

// Client-side verification
const verifyResponse = (response) => {
  const { data, signature, timestamp } = response;
  
  // Check timestamp (prevent replay attacks)
  if (Date.now() - timestamp > 60000) { // 1 minute
    throw new Error('Response expired');
  }
  
  const expectedSignature = signData(data, process.env.SIGNING_SECRET);
  
  if (signature !== expectedSignature) {
    throw new Error('Invalid signature');
  }
  
  return data;
};

CI/CD Pipeline Security

# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

jobs:
  security-checks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Run dependency audit
        run: npm audit --audit-level=high
      
      - name: Run SAST scan
        uses: github/codeql-action/analyze@v2
      
      - name: Verify package integrity
        run: npm ci --prefer-offline
  
  deploy:
    needs: security-checks
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to production
        env:
          DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
        run: |
          # Deployment steps

9. Security Logging and Monitoring Failures

The Problem

Without proper logging and monitoring, breaches can go undetected for months.

Implementation: Comprehensive Logging

Structured Logging with Winston

import winston from 'winston';

const logger = winston.createLogger({
  level: process.env.LOG_LEVEL || 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.errors({ stack: true }),
    winston.format.json()
  ),
  defaultMeta: { service: 'api' },
  transports: [
    new winston.transports.File({ 
      filename: 'logs/error.log', 
      level: 'error' 
    }),
    new winston.transports.File({ 
      filename: 'logs/combined.log' 
    })
  ]
});

if (process.env.NODE_ENV !== 'production') {
  logger.add(new winston.transports.Console({
    format: winston.format.simple()
  }));
}

// Security event logging
const logSecurityEvent = (event, details) => {
  logger.warn('Security Event', {
    type: event,
    ...details,
    timestamp: new Date().toISOString()
  });
};

// Usage in middleware
app.post('/api/login', async (req, res) => {
  const { email, password } = req.body;
  
  const user = await User.findOne({ email });
  
  if (!user || !(await verifyPassword(password, user.password))) {
    logSecurityEvent('failed_login', {
      email,
      ip: req.ip,
      userAgent: req.get('user-agent')
    });
    
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  logSecurityEvent('successful_login', {
    userId: user.id,
    email: user.email,
    ip: req.ip
  });

  res.json({ token: generateToken(user) });
});

Real-time Alerting

import nodemailer from 'nodemailer';

const transporter = nodemailer.createTransport({
  service: 'gmail',
  auth: {
    user: process.env.ALERT_EMAIL,
    pass: process.env.ALERT_PASSWORD
  }
});

const sendSecurityAlert = async (subject, details) => {
  await transporter.sendMail({
    from: process.env.ALERT_EMAIL,
    to: process.env.SECURITY_TEAM_EMAIL,
    subject: `🚨 Security Alert: ${subject}`,
    html: `
      <h2>Security Alert</h2>
      <pre>${JSON.stringify(details, null, 2)}</pre>
      <p>Time: ${new Date().toISOString()}</p>
    `
  });
};

// Detect suspicious activity
const suspiciousActivityDetector = async (req, res, next) => {
  const userId = req.user?.id;
  const key = `activity:${userId || req.ip}`;
  
  const count = await redis.incr(key);
  await redis.expire(key, 60); // 1 minute window
  
  if (count > 100) { // More than 100 requests per minute
    await sendSecurityAlert('Suspicious Activity Detected', {
      userId,
      ip: req.ip,
      requestCount: count,
      endpoint: req.path
    });
    
    return res.status(429).json({ error: 'Too many requests' });
  }
  
  next();
};

10. Server-Side Request Forgery (SSRF)

The Problem

SSRF flaws occur when a web application fetches a remote resource without validating the user-supplied URL.

Implementation: URL Validation

import { URL } from 'url';
import axios from 'axios';

// Whitelist of allowed domains
const ALLOWED_DOMAINS = [
  'api.example.com',
  'cdn.example.com'
];

// Blacklist of internal IP ranges
const isInternalIP = (hostname) => {
  const internalRanges = [
    /^127\./,          // Loopback
    /^10\./,           // Private network
    /^172\.(1[6-9]|2[0-9]|3[0-1])\./, // Private network
    /^192\.168\./,     // Private network
    /^169\.254\./,     // Link-local
    /^localhost$/i,
    /^0\.0\.0\.0$/
  ];
  
  return internalRanges.some(range => range.test(hostname));
};

const validateURL = (urlString) => {
  try {
    const url = new URL(urlString);
    
    // Only allow HTTP(S)
    if (!['http:', 'https:'].includes(url.protocol)) {
      throw new Error('Invalid protocol');
    }
    
    // Check against whitelist
    if (!ALLOWED_DOMAINS.includes(url.hostname)) {
      throw new Error('Domain not allowed');
    }
    
    // Block internal IPs
    if (isInternalIP(url.hostname)) {
      throw new Error('Internal IPs not allowed');
    }
    
    return url.href;
  } catch (error) {
    throw new Error('Invalid URL');
  }
};

// Fetch external resource safely
app.post('/api/fetch-resource', authenticate, async (req, res) => {
  const { url } = req.body;
  
  try {
    const validatedURL = validateURL(url);
    
    const response = await axios.get(validatedURL, {
      timeout: 5000,
      maxRedirects: 0, // Don't follow redirects
      maxContentLength: 1024 * 1024 // 1MB limit
    });
    
    res.json(response.data);
  } catch (error) {
    logSecurityEvent('ssrf_attempt', {
      userId: req.user.id,
      requestedURL: url,
      error: error.message
    });
    
    res.status(400).json({ error: 'Invalid request' });
  }
});

🚀 Pro Tips

1. Security Headers Testing

Use securityheaders.com to scan your deployed application and get an A+ rating.

2. Automate Security in Your Workflow

# Add to pre-commit hook
npm audit --audit-level=moderate && npm run lint:security

3. Use TypeScript for Type Safety

TypeScript catches entire classes of bugs at compile time:

// Prevents passing wrong types to sensitive functions
interface User {
  id: string;
  email: string;
  role: 'user' | 'admin' | 'moderator';
}

const authorize = (user: User, requiredRole: User['role']) => {
  // TypeScript ensures role is one of the allowed values
  return user.role === requiredRole;
};

4. Implement Security.txt

Create a public/.well-known/security.txt file:

Contact: security@yourapp.com
Expires: 2027-01-01T00:00:00.000Z
Preferred-Languages: en
Canonical: https://yourapp.com/.well-known/security.txt

5. Regular Penetration Testing

Schedule quarterly penetration tests and use tools like:

  • OWASP ZAP (free, open-source)
  • Burp Suite (freemium)
  • npm audit / Snyk (dependency scanning)

Common Mistakes to Avoid

1. Rolling Your Own Crypto

Never implement your own encryption algorithms. Use battle-tested libraries like bcrypt, crypto, and jsonwebtoken.

2. Trusting Client-Side Validation

Always validate on the server. Client-side validation is UX, not security.

3. Logging Sensitive Data

// ❌ DON'T
logger.info('User login', { email, password });

// ✅ DO
logger.info('User login', { email, ip: req.ip });

4. Ignoring Dependency Updates

Set up Dependabot or Renovate to automatically create PRs for dependency updates.

5. Over-Permissive CORS

// ❌ DON'T
app.use(cors({ origin: '*' }));

// ✅ DO
app.use(cors({ 
  origin: process.env.ALLOWED_ORIGINS.split(','),
  credentials: true 
}));

📌 Key Takeaways

  1. Security is a journey, not a destination - Continuously update and improve your security posture

  2. Defense in depth - Implement multiple layers of security controls

  3. Validate everything - Never trust user input, always validate and sanitize

  4. Use established libraries - Don't reinvent the wheel for security-critical functions

  5. Log security events - You can't fix what you don't know about

  6. Keep dependencies updated - Automate security scans and updates

  7. Fail securely - Error messages should be informative to developers but vague to users

  8. Encrypt sensitive data - Both in transit (HTTPS) and at rest (encryption at database level)

  9. Implement proper authentication - Use JWT with refresh tokens and consider MFA

  10. Test your security - Regular audits, penetration tests, and code reviews are essential

Conclusion

Building secure web applications requires a proactive mindset and systematic implementation of security controls at every layer. The OWASP Top 10 provides an excellent framework, but security is an ongoing process that requires continuous learning and adaptation.

By implementing the patterns and practices outlined in this guide, you'll have a solid foundation for building secure applications in the modern JavaScript ecosystem. Remember that security is not a checklist to complete but a continuous practice to refine.

Start small—pick one area from this guide and implement it in your current project today. Then gradually expand your security coverage until it becomes second nature.

Stay secure, and happy coding! 🔒


Additional Resources:

All Articles
SecurityJavaScriptOWASPWeb DevelopmentReactNode.jsBest Practices

Written by

Niraj Kumar

Software Developer — building scalable systems for businesses.