Web Security Basics: Every Developer Must Know (2026)
Security isn't just for security teams. Every developer who writes code that touches the web needs to know these basics. Your users are counting on it.
HTTPS & TLS: The Foundation
How HTTPS works (simplified):
1. Browser connects to server
2. Server presents its SSL/TLS certificate (proves identity)
3. Browser verifies certificate with a trusted Certificate Authority (CA)
4. Both parties negotiate encryption parameters (TLS handshake)
5. All data is encrypted before transmission
Without HTTPS:
→ Anyone on the network can read/modify your traffic (coffee shop WiFi!)
→ Login credentials sent in plain text
→ Mixed content warnings in browsers
With HTTPS:
→ Data is encrypted end-to-end
→ Users see the padlock icon
→ Required for modern features (Service Workers, HTTP/2, etc.)
# Get free SSL certificate with Let's Encrypt:
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com
# Auto-renews! Set up cron if needed.
# Test your SSL configuration:
curl -Iv https://yourdomain.com # Check headers
openssl s_client -connect yourdomain.com:443 # Check cert details
# Online test: ssllabs.com/ssltest (gives grade A-F)
Authentication Essentials
// === Password Storage (NEVER store plain text!) ===
// ❌ Terrible:
db.users.insert({ email, password: 'secret123' }); // Anyone who sees DB has all passwords!
// ✅ Use bcrypt (purpose-built for passwords):
const bcrypt = require('bcrypt');
const SALT_ROUNDS = 12; // Higher = slower = more secure (but don't overdo it)
async function hashPassword(plainPassword) {
return await bcrypt.hash(plainPassword, SALT_ROUNDS);
}
// Verify at login:
async function login(email, password) {
const user = await db.users.findOne({ email });
if (!user) throw new AuthError('Invalid credentials');
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) throw new AuthError('Invalid credentials');
return createSession(user);
}
// bcrypt automatically handles salt → every hash is unique even for same password!
// bcrypt is slow by design → makes brute-force attacks expensive
// === Session Management ===
// After successful login, how do you keep user "logged in"?
// Option A: Session cookies (server-side):
app.use(session({
secret: crypto.randomBytes(32).toString('hex'), // Random! Long! Change per deploy!
resave: false,
saveUninitialized: false,
cookie: {
secure: true, // Only send over HTTPS
httpOnly: true, // JavaScript can't read (prevents XSS token theft!)
sameSite: 'strict', // Prevents CSRF attacks
maxAge: 3600000, // 1 hour
}
}));
// Server stores session data → client only gets an opaque session ID cookie
// Option B: JWT tokens (stateless):
const jwt = require('jsonwebtoken');
function generateToken(user) {
return jwt.sign(
{ userId: user.id, role: user.role },
process.env.JWT_SECRET, // Keep this SECRET!
{ expiresIn: '15m' } // Short-lived access tokens!
);
}
// Client stores token (usually localStorage or httpOnly cookie)
// Server verifies signature on each request (no DB lookup needed)
// ⚠️ JWT can't be easily revoked — use short expiry + refresh tokens
// === Multi-Factor Authentication (MFA) ===
// Something you know (password) + something you have (phone/app)
// Libraries: speakeasy, otplib, qrcode
const speakeasy = require('speakeasy');
const QRCode = require('qrcode');
// Setup MFA for user:
const secret = speakeasy.generateSecret({ name: `MyApp (${user.email})` });
// Save secret.encrypted to DB (encrypt it!)
// Show user QR code to scan with Authenticator app:
const qrUrl = secret.otpauth_url;
QRCode.toDataURL(qrUrl); // Send to frontend to display
// Verify MFA code during login:
const verified = speakeasy.totp.verify({
secret: user.mfaSecret,
encoding: 'base32',
token: userInputCode, // 6-digit code from authenticator app
window: 2 // Allow 2 periods of clock drift (~60 seconds)
});
Common Vulnerabilities & How to Prevent Them
XSS (Cross-Site Scripting)
// Attack: Attacker injects JavaScript into your page
// <script>stealCookies()</script>
// <img src=x onerror="alert(1)">
// Prevention:
// 1. Output encoding (escape user content before rendering):
import escapeHtml from 'escape-html';
const safeOutput = escapeHtml(userInput); // Converts < to < etc.
// 2. Content Security Policy (CSP): tells browser which scripts are allowed:
// In Express/helmet:
helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", 'cdn.example.com'],
styleSrc: ["'self'", "'unsafe-inline'"], // Needed for some CSS frameworks
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'", 'api.example.com'],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
frameSrc: ["'none'"],
},
});
// 3. HttpOnly cookies (JavaScript can't read them):
cookie: { httpOnly: true }
// 4. Sanitize HTML (if you MUST allow some HTML):
import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(dirtyHTML); // Removes dangerous tags
CSRF (Cross-Site Request Forgery)
// Attack: User visits evil.com while logged into your site.
// evil.com has: <img src="https://yoursite.com/transfer?to=attacker&amount=1000">
// Browser automatically sends the request WITH the user's cookies!
// Prevention: CSRF Tokens
const csrf = require('csurf');
// Setup middleware:
app.use(csrf({ cookie: true }));
// Include token in forms (server renders it):
app.get('/form', (req, res) => {
res.render('form', { csrfToken: req.csrfToken() });
});
// In template: <input type="hidden" name="_csrf" value="<%= csrfToken %>">
// For API requests: include in header or custom header:
// X-CSRF-Token header (checked automatically by csurf middleware)
// Alternative: SameSite=strict cookies (modern approach):
// If cookie has sameSite: strict, browser won't send it on cross-site requests.
// This effectively prevents CSRF without tokens!
SQL Injection
// Attack: User input modifies SQL query logic
// Input: ' OR 1=1 --
// Query becomes: SELECT * FROM users WHERE email='' OR 1=1--' AND password='...'
// Returns ALL users → bypasses authentication!
// Prevention: Parameterized queries (ALWAYS!)
// ❌ Vulnerable:
const result = await db.query(`SELECT * FROM users WHERE email = '${email}'`);
// ✅ Safe (parameterized):
const result = await db.query('SELECT * FROM users WHERE email = $1', [email]);
// Database treats $1 as DATA, never as SQL code.
// Using ORM (most do this automatically):
const user = await User.findOne({ where: { email } }); // Safe by default
// But be careful with raw queries in ORMs:
await sequelize.query(`SELECT * FROM ${tableName}`); // tableName could be malicious!
Security Headers Checklist
// Express + helmet.js gives you most of these out of the box:
const helmet = require('helmet');
app.use(helmet());
// Manual verification checklist:
// 1. X-Content-Type-Options: nosniff
// → Prevents browser from MIME-type sniffing (treats file as declared type)
// 2. X-Frame-Options: DENY or SAMEORIGIN
// → Prevents clickjacking attacks (your site embedded in attacker's iframe)
// 3. Strict-Transport-Security: max-age=31536000; includeSubDomains
// → Forces HTTPS for 1 year after first visit
// 4. X-XSS-Protection: 0 (DISABLED!)
// → Old browser feature, can introduce vulnerabilities. Use CSP instead.
// 5. Content-Security-Policy (see above under XSS)
// 6. Referrer-Policy: strict-origin-when-cross-origin
// → Controls what info is sent in Referer header
// 7. Permissions-Policy: camera=(), microphone=(), geolocation=()
// → Controls which browser APIs your site can use
// Check your headers:
curl -I https://yoursite.com | grep -iE 'x-content|x-frame|hsts|csp|referrer'
// Or online: securityheaders.com (gives grade A+ to F)
What security topic confuses you most? What's the worst vulnerability you've found in production?
Follow @armorbreak for more practical developer guides.
Top comments (0)