Web Security Basics Every Developer Must Know (2026)
Security isn't a feature you add later — it's built into every layer. Here's what every developer needs to know to keep their applications safe.
The Threat Model
Who are you protecting against?
1. Script kiddies → Automated scanners, basic attacks
2. Opportunists → Looking for easy targets (unpatched vulnerabilities)
3. Targeted attackers → Specific goals (data theft, ransom)
4. Insiders → Employees with access
What are you protecting?
- User data (PII, passwords, payment info)
- Business data (revenue figures, trade secrets)
- System integrity (availability, reputation)
- User trust (once lost, almost impossible to regain)
The security mindset:
"Never trust input. Always validate. Always sanitize.
Encrypt in transit AND at rest. Use the principle of least privilege."
OWASP Top 10 (2025 Edition) — Practical Guide
// === 1. Broken Access Control ===
// Problem: Users can access resources they shouldn't
// Fix: ALWAYS check authorization on EVERY endpoint
// ❌ Only checks authentication (is user logged in?)
app.get('/api/users/:id', authRequired, async (req, res) => {
const user = await db.users.findById(req.params.id);
// Any logged-in user can access ANY user's data!
});
// ✅ Check ownership/authorization
app.get('/api/users/:id', authRequired, async (req, res) => {
if (req.params.id !== req.user.id && !req.user.isAdmin) {
return res.status(403).json({ error: 'Forbidden' });
}
const user = await db.users.findById(req.params.id);
});
// Resource-based access control pattern:
function requireOwnership(model) {
return async (req, res, next) => {
const resource = await model.findById(req.params.id);
if (!resource) return res.status(404).json({ error: 'Not found' });
if (resource.userId !== req.user.id && !req.user.isAdmin) {
return res.status(403).json({ error: 'Forbidden' });
}
req.resource = resource;
next();
};
}
app.put('/api/posts/:id', authRequired, requireOwnership(Post), updatePost);
// === 2. Cryptographic Failures ===
// Problem: Sensitive data stored or transmitted unencrypted
// Fix: Encrypt at rest, use TLS in transit, proper key management
const crypto = require('crypto');
// Password hashing (NEVER store plaintext!):
async function hashPassword(password) {
// Argon2id is the current gold standard
// bcrypt is also excellent and widely supported
const salt = crypto.randomBytes(16);
// Use argon2 or bcrypt library:
// return await argon2.hash(password);
return await bcrypt.hash(password, 12); // 12 rounds = good balance of speed/security
}
// Verify password:
async function verifyPassword(password, hash) {
return await bcrypt.compare(password, hash);
}
// NEVER roll your own crypto!
// ❌ Custom "encryption" = base64 + XOR = NOT encryption
// ✅ Use established libraries: bcrypt, argon2, sodium, node:crypto
// API keys & secrets:
// - Store in environment variables (never in code!)
// - Rotate regularly
// - Use different keys per environment
// - Never log secrets (use redaction middleware)
// Data classification:
// PUBLIC: Can be freely shared (product names, public content)
// INTERNAL: Company internal (employee directory, internal docs)
// CONFIDENTIAL: User PII, business data (encrypt at rest!)
// RESTRICTED: Payment data, health records (PCI/HIPAA compliance)
// === 3. Injection (SQL, NoSQL, Command, LDAP) ===
// Problem: Attacker-supplied data interpreted as commands
// Fix: Parameterized queries, input validation, output encoding
// SQL Injection — the classic:
// ❌ String concatenation (VULNERABLE):
const query = `SELECT * FROM users WHERE id = ${userId}`;
// userId = "1 OR 1=1" → Returns ALL users!
// ✅ Parameterized queries (SAFE):
const user = await db.query(
'SELECT * FROM users WHERE id = $1',
[userId] // Parameterized — database treats as data, not code
);
// NoSQL Injection (MongoDB):
// ❌ Vulnerable:
const user = await db.users.find({ username: req.body.username, password: req.body.password });
// { username: { "$ne": "" }, password: { "$ne": "" } } → Bypasses login!
// ✅ Safe: Type checking + allowlist:
if (typeof req.body.username !== 'string' || typeof req.body.password !== 'string') {
return res.status(400).json({ error: 'Invalid input' });
}
const user = await db.users.findOne({
username: req.body.username,
password: hashedPassword, // Compare hashes, never raw passwords
});
// Command Injection:
// ❌ VULNER:
const { execSync } = require('child_process');
execSync(`convert ${userInput} output.png`);
// userInput = "; rm -rf / #" → DELETES EVERYTHING!
// ✅ Safe: Validation + allowlist of characters:
function sanitizeFilename(input) {
if (!/^[a-zA-Z0-9._-]+$/.test(input)) throw new Error('Invalid filename');
if (input.includes('..')) throw new Error('Path traversal detected');
return path.basename(input); // Strip any path components
}
execSync(`convert "${sanitizeFilename(userInput)}" output.png`);
// General injection prevention rules:
// 1. Use parameterized queries for ALL database operations
// 2. Validate input type, length, format BEFORE using it
// 3. Use an ORM that handles escaping properly (Prisma, Sequelize, TypeORM)
// 4. For shell commands: avoid exec() entirely, use library APIs instead
// 5. When exec() is unavoidable: strict allowlist validation
// === 4. Insecure Design ===
// Problem: Security not considered during design phase
// Fix: Threat modeling, abuse case testing, secure by default
// Example: Password reset flow
// ❌ Insecure design: Reset token sent via email, no expiration, reusable
// ✅ Secure design:
// - Token expires in 15 minutes
// Token is single-use (invalidated after use)
// Token is cryptographically strong (128+ bits of entropy)
// Rate limiting on reset requests (max 3/hour per email)
// Notify user when password is changed (detect unauthorized resets)
// Log all reset attempts for audit trail
// Abuse cases to think about for EVERY feature:
// "What if someone calls this API 10,000 times?" → Rate limiting
// "What if someone submits <script>alert(1)</script>?" → Input sanitization
// "What if someone modifies the form data?" → Server-side validation
// "What if someone accesses another user's URL?" → Authorization check
// "What if someone intercepts the request?" → CSRF tokens, TLS
// === 5. Security Misconfiguration ===
// Problem: Default configs, unnecessary features, verbose errors
// Fix: Hardened defaults, minimal attack surface, proper headers
// Express.js security middleware setup:
const helmet = require('helmet'); // Security headers
const rateLimit = require('express-rate-limit'); // Rate limiting
const cors = require('cors'); // CORS configuration
// Security headers (Helmet handles most):
app.use(helmet({
contentSecurityPolicy: { // Prevents XSS via injected scripts
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"], // Remove unsafe-inline when possible
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "https://api.example.com"],
},
},
hsts: { maxAge: 31536000, includeSubDomains: true }, // Force HTTPS
noSniff: true, // Prevent MIME sniffing
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}));
// Rate limiting:
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per windowMs
message: { error: 'Too many requests, please try again later.' },
standardHeaders: true,
legacyHeaders: false,
});
app.use(limiter);
// Stricter limits for auth endpoints:
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5, // Only 5 login attempts per 15 minutes
message: { error: 'Too many attempts. Account locked for 15 min.' },
});
app.post('/api/login', authLimiter, handleLogin);
// Disable sensitive endpoints in production:
if (process.env.NODE_ENV === 'production') {
app.disable('x-powered-by'); // Don't reveal Express
// Don't expose stack traces to users:
app.use((err, req, res, next) => {
res.status(err.status || 500).json({
error: process.env.NODE_ENV === 'production' ? 'Internal error' : err.message,
...(process.env.NODE_ENV !== 'production' && { stack: err.stack }),
});
});
}
// CORS — be specific, don't use wildcard in production:
app.use(cors({
origin: ['https://example.com', 'https://www.example.com'], // Allowlisted only
credentials: true, // Allow cookies/auth headers
methods: ['GET', 'POST', 'PUT', 'DELETE'], // Restrict methods
allowedHeaders: ['Content-Type', 'Authorization'],
}));
// === 6. Vulnerable & Outdated Components ===
// Problem: Using libraries with known vulnerabilities
// Fix: Regular dependency auditing, automated updates
// Audit dependencies:
npm audit # Check for known vulnerabilities
npm audit fix # Auto-fix where possible
npm outdated # Check for outdated packages
// Add to CI pipeline:
// npm audit --audit-level=moderate || exit 1
// Lockfile integrity:
// Always commit package-lock.json!
# It ensures everyone installs exact same versions
# Without it: npm install might pull different (vulnerable!) versions
// Automated dependency updates:
# Dependabot (GitHub), Renovate, Snyk
# Set up PRs for security patches automatically
// Pin critical dependencies:
{
"dependencies": {
"lodash": "^4.17.21", // Allow patch/minor updates
"express": "4.18.2", // Pin exact version for critical apps
}
}
// === 7. Authentication & Session Management Failures ===
// Problem: Weak auth, session hijacking, credential stuffing
// Fix: MFA, secure sessions, strong password policies
// Session security:
const session = require('express-session');
app.use(session({
secret: process.env.SESSION_SECRET, // Strong random secret (rotate regularly!)
name: '__Host-sid', // Prefix: __Host = cookie only sent over HTTPS
cookie: {
secure: true, // Only over HTTPS
httpOnly: true, // Not accessible via JavaScript (prevents XSS theft)
sameSite: 'lax', // CSRF protection
maxAge: 3600000, // 1 hour session
domain: 'example.com', // Restrict domain
path: '/', // Cookie path
},
rolling: false, // Don't extend session on every request
resave: false,
saveUninitialized: false, // Don't create sessions for unauthenticated users
}));
// JWT best practices:
const jwt = require('jsonwebtoken');
function generateToken(user) {
return jwt.sign(
{ sub: user.id, role: user.role }, // Minimal payload (no PII!)
process.env.JWT_SECRET,
{ expiresIn: '15m', algorithm: 'HS256', issuer: 'example.com' }
);
}
// Short-lived access token + refresh token pattern:
// Access token: 15min (stored in memory/httpOnly cookie)
// Refresh token: 7 days (stored in httpOnly cookie, one-time use, rotated)
// This limits damage from token theft!
// MFA (Multi-Factor Authentication):
// Require MFA for:
// - New device login
// - Password change
// - Sensitive operations (payments, data export)
// - Admin panel access
Security Checklist for Every Deploy
# Before deploying ANYTHING:
[ ] Dependencies audited? (npm audit / pip audit)
[ ] Environment variables set correctly? (No dev secrets in prod!)
[ ] HTTPS/TLS configured?
[ ] Security headers present? (CSP, HSTS, X-Frame-Options)
[ ] Rate limiting enabled?
[ ] CORS configured properly (not wildcard)?
[ ] Error messages sanitized (no stack traces)?
[ ] Logging enabled (but no secrets in logs)?
[ ] Database migrations run with correct permissions?
[ ] Backup strategy tested?
[ ] DDoS protection active? (Cloudflare, AWS Shield)
[ ] Authentication flows tested?
[ ] Input validation on ALL endpoints?
[ ] CSP (Content-Security-Policy) headers set?
# Run this checklist automatically in CI:
# npx snyk test # Dependency vulnerability scan
# npm run security:test # Your custom security tests
Security is a journey, not a destination. What's the most important security lesson you've learned the hard way?
Follow @armorbreak for more practical developer guides.
Top comments (0)