Web Security Basics Every Developer Must Know (2026)
Security isn't just for security teams. Every developer needs these fundamentals to protect their applications and users.
The Threat Landscape in 2026
Most common attacks targeting web apps:
1. SQL Injection — Still #1, still devastating
2. XSS (Cross-Site Scripting) — Steals sessions, defaces sites
3. CSRF (Cross-Site Request Forgery) — Actions on behalf of users
4. Authentication bypass — Weak passwords, session fixation
5. Sensitive data exposure — API keys in code, unencrypted data
6. IDOR (Broken Access Control) — Accessing others' data
7. SSRF (Server-Side Request Forgery) — Internal network probing
8. Dependency vulnerabilities — Compromised npm/pip packages
Key principle: Defense in depth
→ Don't rely on one security layer
→ Multiple independent controls
→ If one fails, others catch it
#1 SQL Injection Prevention
// ❌ VULNERABLE: String concatenation
const query = `SELECT * FROM users WHERE email = '${email}'`;
// Attacker inputs: ' OR '1'='1' --
// Result: Returns ALL users!
// ✅ Parameterized queries (always!)
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
// The database treats the input as DATA, not code.
// With ORM (Sequelize/TypeORM/Prisma):
User.findOne({ where: { email } }); // Safe by default
// Even with parameterized queries, validate input first:
function isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
if (!isValidEmail(email)) return res.status(400).json({ error: 'Invalid email' });
// ⚠️ Dangerous: Dynamic table/column names can't be parameterized!
const allowedTables = ['users', 'products', 'orders'];
if (!allowedTables.includes(table)) throw new Error('Invalid table');
#2 XSS (Cross-Site Scripting) Defense
// Types of XSS:
// 1. Stored XSS: Malicious script saved in DB, shown to all viewers
// → Comment sections, profiles, product reviews
// 2. Reflected XSS: Script in URL, reflected back in response
// → Search results, error messages
// 3. DOM-based XSS: Client-side script modifies DOM unsafely
// → innerHTML, document.write(), eval()
// ❌ Vulnerable:
app.get('/search', (req, res) => {
res.send(`Results for: ${req.query.q}`); // Script injection!
});
// ✅ Output encoding:
const escapeHtml = (str) => str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
res.send(`Results for: ${escapeHtml(req.query.q)}`);
// ✅ For rich content, use a sanitizer:
import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(userContent);
// ✅ Set Content-Type headers (prevents MIME sniffing):
res.setHeader('Content-Type', 'application/json; charset=utf-8');
// Browser won't try to render JSON as HTML
// ✅ CSP (Content Security Policy):
res.setHeader('Content-Security-Policy',
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'"
);
#3 CSRF Protection
// How CSRF works:
// 1. User is logged into your site (has valid cookie)
// 2. User visits attacker's site
// 3. Attacker's page submits a form to YOUR site
// 4. Browser automatically sends cookies → action executed as logged-in user!
// ✅ Solution 1: CSRF tokens (most common)
import crypto from 'crypto';
// Generate token on server:
function generateCsrfToken() {
return crypto.randomBytes(32).toString('hex');
}
// Store in session:
app.use((req, res, next) => {
if (!req.session.csrfToken) {
req.session.csrfToken = generateCsrfToken();
}
next();
});
// Send to client (cookie or template variable):
res.cookie('XSRF-TOKEN', req.session.csrfToken, { httpOnly: false });
// httpOnly: false so JavaScript can read it and send in header
// Validate on every state-changing request:
app.post('/api/update', (req, res) => {
const token = req.headers['x-csrf-token'] || req.body._csrf;
if (token !== req.session.csrfToken) {
return res.status(403).json({ error: 'CSRF token mismatch' });
}
// Process request...
});
// ✅ Solution 2: SameSite cookies (modern approach)
res.setHeader('Set-Cookie', [
'sessionId=abc123; Path=/; HttpOnly; Secure; SameSite=Strict',
// Strict: Never send cross-site (safest, breaks some legitimate use cases)
// Lax: Send on top-level navigation (GET), not on requests from other sites
// None: Always send (required for cross-site POSTs, must also set Secure)
]);
// ✅ Solution 3: Check Origin/Referer header
app.use((req, res, next) => {
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) {
const origin = req.headers.origin || req.headers.referer?.split('/')[2];
const allowed = ['https://myapp.com', 'https://www.myapp.com'];
if (origin && !allowed.some(o => o.endsWith(origin))) {
return res.status(403).json({ error: 'Origin not allowed' });
}
}
next();
});
#4 Authentication Best Practices
// Password hashing (NEVER store plaintext!)
import bcrypt from 'bcryptjs';
// Hash on registration:
const hash = await bcrypt.hash(password, 12); // Cost factor 12 (adjust for CPU)
// Verify on login:
const valid = await bcrypt.compare(password, storedHash);
// bcrypt handles salting automatically (unique salt per password)
// Session management:
import { sign, verify } from 'jsonwebtoken';
const token = sign(
{ userId: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '15m' } // Short-lived access tokens!
);
// Refresh token pattern (longer-lived, stored securely):
const refreshToken = crypto.randomBytes(40).toString('hex');
await db.storeRefreshToken(userId, refreshToken, { expiresIn: '7d' });
// Rate limit auth endpoints:
import rateLimit from 'express-rate-limit';
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10, // 10 attempts per 15 minutes per IP
message: { error: 'Too many attempts, try again later' },
});
app.post('/api/login', loginLimiter, handleLogin);
// Account lockout after failed attempts:
async function checkAccountLock(email) {
const attempts = await getFailedAttempts(email);
if (attempts.count >= 5 && attempts.lastAttempt > Date.now() - 15 * 60 * 1000) {
throw new Error('Account temporarily locked');
}
}
#5 Input Validation Framework
// Validate EVERY external input (form data, API params, file uploads)
const Joi = require('joi');
const schemas = {
register: Joi.object({
email: Joi.string().email().max(254).required(),
password: Joi.string()
.min(8).max(128)
.pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
.required()
.messages({
'string.pattern.base': 'Password must contain uppercase, lowercase, and number'
}),
name: Joi.string().min(1).max(100).trim().required(),
role: Joi.string().valid('user', 'creator').default('user'),
}),
pagination: Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(100).default(20),
sort: Joi.string().valid('name', 'date', 'popularity').default('date'),
order: Joi.string().valid('asc', 'desc').default('desc'),
}),
};
// Middleware:
function validate(schema) {
return (req, res, next) => {
const { error, value } = schema.validate(req.body, {
abortEarly: false, // Return ALL errors, not just first
stripUnknown: true, // Remove unexpected fields
});
if (error) {
return res.status(400).json({
error: 'Validation failed',
details: error.details.map(d => ({
field: d.path.join('.'),
message: d.message,
})),
});
}
req.body = value; // Use sanitized values
next();
};
}
app.post('/api/register', validate(schemas.register), handleRegister);
#6 Security Headers Checklist
import helmet from 'helmet';
app.use(helmet({
contentSecurityPolicy: { // Prevents XSS, data injection
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
},
},
hsts: { maxAge: 31536000, includeSubDomains: true }, // Force HTTPS
noSniff: true, // Prevent MIME sniffing
frameguard: { action: 'deny' }, // Prevent clickjacking
xssFilter: true, // Basic XSS filter
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}));
// Additional custom headers:
app.use((req, res, next) => {
res.removeHeader('X-Powered-By'); // Hide server info
// Rate limiting info
res.setHeader('X-RateLimit-Limit', '100');
res.setHeader('X-RateLimit-Remaining', String(99 - getUsedQuota(req.ip)));
next();
});
Pre-Deployment Security Checklist
□ All user input validated AND sanitized?
□ Database uses parameterized queries everywhere?
□ Passwords hashed with bcrypt/argon2?
□ JWT tokens short-lived + refresh token rotation?
□ Auth endpoints rate-limited?
□ CSRF protection enabled (tokens or SameSite)?
□ Security headers configured (helmet.js)?
□ Secrets in environment variables (never in code)?
□ Error messages don't leak internals (stack traces, paths)?
□ CORS configured with explicit allowlist?
□ Dependencies audited (npm audit --json)?
□ Access controls checked (users access only THEIR data)?
□ File uploads validated (type AND content, not just extension)?
□ HTTPS enforced everywhere?
□ Logging doesn't capture passwords/tokens?
□ Admin endpoints have additional protection?
Score yourself: Each ☑️ blocks an entire attack category.
What's the most important security practice you follow? What's one you always forget?
Follow @armorbreak for more practical developer guides.
Top comments (0)