Web Application Security: Essential Practices Every Developer Must Know
Security breaches make headlines daily, yet I still review codebases with passwords stored in plain text, SQL queries built with string concatenation, and admin panels protected by nothing but hope. The frustrating part? Most vulnerabilities are completely preventable with basic security practices.
Let’s fix that. This isn’t a theoretical security course—it’s a practical guide to the security measures that actually prevent real attacks.
The Security Mindset
Security isn’t a feature you add at the end. It’s not a checklist item. It’s a mindset shift: never trust user input, and always assume someone is trying to break your system.
Because they are. Automated bots scan for vulnerabilities 24/7. It’s not personal—it’s just profitable.
Authentication: Getting It Right
Authentication determines who users are. Get this wrong, and nothing else matters.
Password Storage (Never Screw This Up)
If you’re storing passwords in plain text, stop reading and fix it now. Seriously.
The Right Way:
const bcrypt = require('bcrypt');
async function hashPassword(password) {
const saltRounds = 12; // CPU cost factor
return await bcrypt.hash(password, saltRounds);
}
async function verifyPassword(password, hash) {
return await bcrypt.compare(password, hash);
}
// Usage
async function registerUser(email, password) {
const hashedPassword = await hashPassword(password);
await db.users.create({
email,
password: hashedPassword // Store the hash, never the password
});
}
Why bcrypt?: It’s slow by design. Brute-forcing takes centuries instead of seconds. Argon2 and scrypt are also excellent choices.
Don’t use: MD5, SHA1, or any hash without salting. These are broken and crack in minutes with modern hardware.
Password Requirements That Actually Work
Forget arbitrary rules like “must have 1 uppercase, 1 number, 1 symbol.” They don’t help.
Do this instead:
function validatePassword(password) {
// Minimum length is the most important factor
if (password.length < 12) {
return { valid: false, error: 'Password must be at least 12 characters' };
}
// Check against common passwords
if (isCommonPassword(password)) {
return { valid: false, error: 'This password is too common' };
}
// Optional: Check against haveibeenpwned
const breached = await checkPwnedPassword(password);
if (breached) {
return { valid: false, error: 'This password has been found in data breaches' };
}
return { valid: true };
}
Length beats complexity. A 16-character password of random words is stronger than “P@ssw0rd123”.
Multi-Factor Authentication (MFA)
Passwords alone aren’t enough anymore. MFA should be standard, not optional.
TOTP Implementation (Google Authenticator style):
const speakeasy = require('speakeasy');
// Generate secret during MFA setup
function setupMFA(userId) {
const secret = speakeasy.generateSecret({
name: `YourApp (${userId})`,
length: 32
});
// Store secret.base32 in database
await db.users.update(userId, {
mfaSecret: secret.base32,
mfaEnabled: false // Not enabled until verified
});
// Return QR code for user to scan
return secret.otpauth_url;
}
// Verify code
function verifyMFACode(secret, token) {
return speakeasy.totp.verify({
secret: secret,
encoding: 'base32',
token: token,
window: 2 // Allow 2 time-steps of variance
});
}
Session Management
Sessions are where authentication often falls apart.
Secure Session Configuration:
const session = require('express-session');
const RedisStore = require('connect-redis')(session);
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET, // Long, random, in env vars
resave: false,
saveUninitialized: false,
name: 'sessionId', // Don't use default 'connect.sid'
cookie: {
secure: true, // HTTPS only
httpOnly: true, // No JavaScript access
sameSite: 'strict',// CSRF protection
maxAge: 3600000 // 1 hour
}
}));
Session Invalidation:
// Always invalidate on logout
app.post('/logout', (req, res) => {
req.session.destroy((err) => {
res.clearCookie('sessionId');
res.redirect('/login');
});
});
// Invalidate all sessions on password change
async function changePassword(userId, newPassword) {
await updatePassword(userId, newPassword);
// Invalidate all sessions for this user
await deleteAllUserSessions(userId);
// Create new session for current request
req.session.regenerate(() => {
req.session.userId = userId;
});
}
Authorization: Who Can Do What
Authentication tells you who someone is. Authorization determines what they can do.
Role-Based Access Control (RBAC)
// Define permissions
const permissions = {
admin: ['read', 'write', 'delete', 'manage_users'],
editor: ['read', 'write'],
viewer: ['read']
};
// Middleware for permission checks
function requirePermission(permission) {
return (req, res, next) => {
const userRole = req.session.user?.role;
if (!userRole || !permissions[userRole]?.includes(permission)) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
}
// Usage
app.delete('/api/posts/:id', requirePermission('delete'), async (req, res) => {
// Only users with 'delete' permission reach here
await deletePost(req.params.id);
res.json({ success: true });
});
Object-Level Permissions
Role checks aren’t enough. Users should only access their own data:
app.get('/api/orders/:id', authenticateUser, async (req, res) => {
const order = await db.orders.findOne({ id: req.params.id });
if (!order) {
return res.status(404).json({ error: 'Not found' });
}
// Check ownership
if (order.userId !== req.session.userId && !req.user.isAdmin) {
return res.status(403).json({ error: 'Forbidden' });
}
res.json(order);
});
SQL Injection: The Classic Vulnerability
SQL injection is ancient, yet it’s still in the OWASP Top 10. Why? Because developers keep building queries with string concatenation.
The Vulnerable Way (Never Do This)
// VULNERABLE - DO NOT USE
const userId = req.params.id;
const query = `SELECT * FROM users WHERE id = ${userId}`;
db.query(query); // Attacker sends "1 OR 1=1" and dumps your database
The Safe Way: Parameterized Queries
// Safe - use parameterized queries
const userId = req.params.id;
const query = 'SELECT * FROM users WHERE id = ?';
db.query(query, [userId]); // Driver escapes the parameter
// With named parameters (even better)
const query = 'SELECT * FROM users WHERE id = :userId';
db.query(query, { userId });
ORM Protection
// ORMs like Prisma, TypeORM, Sequelize prevent SQL injection by default
const user = await prisma.user.findUnique({
where: { id: userId } // Automatically parameterized
});
// But you can still screw it up with raw queries
const users = await prisma.$queryRaw`SELECT * FROM users WHERE id = ${userId}`; // VULNERABLE
// Safe raw query
const users = await prisma.$queryRaw`SELECT * FROM users WHERE id = ${userId}`; // Actually safe - tagged template
Cross-Site Scripting (XSS)
XSS lets attackers inject malicious JavaScript into your pages. It’s devastating: steal sessions, capture keystrokes, deface pages, or redirect to phishing sites.
Types of XSS
Stored XSS: Malicious script saved in database Reflected XSS: Script in URL parameter DOM XSS: Client-side JavaScript vulnerability
Prevention: Output Encoding
Never insert untrusted data directly into HTML:
// VULNERABLE
app.get('/profile', (req, res) => {
const name = req.query.name;
res.send(`<h1>Welcome ${name}</h1>`); // XSS vulnerability
});
// Safe - escape HTML
const escapeHtml = (str) => {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
};
app.get('/profile', (req, res) => {
const name = escapeHtml(req.query.name);
res.send(`<h1>Welcome ${name}</h1>`);
});
Better: Use Template Engines That Auto-Escape:
// React automatically escapes
function Profile({ name }) {
return <h1>Welcome {name}</h1>; // Safe - React escapes by default
}
// Unless you explicitly bypass it
function UnsafeProfile({ name }) {
return <h1 dangerouslySetInnerHTML={{ __html: name }} />; // DANGEROUS
}
Content Security Policy (CSP)
CSP is your last line of defense against XSS:
app.use((req, res, next) => {
res.setHeader(
'Content-Security-Policy',
[
"default-src 'self'", // Only load from same origin
"script-src 'self' 'unsafe-inline'", // Scripts from same origin (inline needed for some frameworks)
"style-src 'self' 'unsafe-inline'", // Styles from same origin
"img-src 'self' data: https:", // Images from self, data URIs, any HTTPS
"font-src 'self'", // Fonts from same origin
"connect-src 'self' https://api.example.com", // API calls
"frame-ancestors 'none'", // Prevent clickjacking
"base-uri 'self'", // Restrict <base> tag
"form-action 'self'" // Only submit forms to same origin
].join('; ')
);
next();
});
Start strict and loosen as needed. Monitor CSP violations to catch attacks.
Cross-Site Request Forgery (CSRF)
CSRF tricks users into performing unwanted actions while authenticated.
The Attack:
- User logs into yourbank.com
- User visits attacker.com
- attacker.com contains:
<img src="https://yourbank.com/transfer?to=attacker&amount=10000" />
- Browser sends request with user’s cookies
- Money transferred
CSRF Tokens
const csrf = require('csurf');
// Enable CSRF protection
const csrfProtection = csrf({ cookie: true });
app.use(csrfProtection);
// Include token in forms
app.get('/transfer', csrfProtection, (req, res) => {
res.render('transfer', { csrfToken: req.csrfToken() });
});
// Verify on submission
app.post('/transfer', csrfProtection, (req, res) => {
// Token verified automatically by middleware
processTransfer(req.body);
});
HTML Form:
<form method="POST" action="/transfer">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<input name="amount" type="number">
<button type="submit">Transfer</button>
</form>
SameSite Cookies
Modern browsers support SameSite cookies, which prevent CSRF automatically:
res.cookie('sessionId', sessionId, {
sameSite: 'strict', // Don't send cookie on cross-site requests
secure: true,
httpOnly: true
});
Data Encryption
Data in Transit: HTTPS Always
There’s no excuse for HTTP in 2025. Free certificates from Let’s Encrypt removed all barriers.
// Redirect HTTP to HTTPS
app.use((req, res, next) => {
if (req.header('x-forwarded-proto') !== 'https') {
return res.redirect(`https://${req.header('host')}${req.url}`);
}
next();
});
// Enforce HTTPS with HSTS
app.use((req, res, next) => {
res.setHeader(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains; preload'
);
next();
});
Data at Rest: Encrypt Sensitive Fields
const crypto = require('crypto');
const ALGORITHM = 'aes-256-gcm';
const KEY = Buffer.from(process.env.ENCRYPTION_KEY, 'hex'); // 32 bytes
function encrypt(text) {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(ALGORITHM, KEY, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
return {
iv: iv.toString('hex'),
encryptedData: encrypted,
authTag: authTag.toString('hex')
};
}
function decrypt(encrypted) {
const decipher = crypto.createDecipheriv(
ALGORITHM,
KEY,
Buffer.from(encrypted.iv, 'hex')
);
decipher.setAuthTag(Buffer.from(encrypted.authTag, 'hex'));
let decrypted = decipher.update(encrypted.encryptedData, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
// Usage
async function storeSSN(userId, ssn) {
const encrypted = encrypt(ssn);
await db.users.update(userId, {
ssn_encrypted: encrypted.encryptedData,
ssn_iv: encrypted.iv,
ssn_auth_tag: encrypted.authTag
});
}
API Security
Rate Limiting
Prevent brute force and DoS attacks:
const rateLimit = require('express-rate-limit');
// General API rate limit
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Max 100 requests per window
message: 'Too many requests, please try again later'
});
app.use('/api/', apiLimiter);
// Stricter limit for auth endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5, // Max 5 login attempts per window
skipSuccessfulRequests: true // Don't count successful logins
});
app.post('/api/login', authLimiter, async (req, res) => {
// Login logic
});
Input Validation
Never trust client input:
const Joi = require('joi');
// Define schema
const userSchema = Joi.object({
email: Joi.string().email().required(),
password: Joi.string().min(12).required(),
age: Joi.number().integer().min(13).max(120),
bio: Joi.string().max(500)
});
// Validation middleware
function validate(schema) {
return (req, res, next) => {
const { error } = schema.validate(req.body);
if (error) {
return res.status(400).json({
error: 'Validation failed',
details: error.details.map(d => d.message)
});
}
next();
};
}
// Usage
app.post('/api/users', validate(userSchema), async (req, res) => {
// req.body is validated
const user = await createUser(req.body);
res.json(user);
});
JWT Security
JWTs are popular but easy to misuse:
const jwt = require('jsonwebtoken');
// Sign tokens
function createToken(userId) {
return jwt.sign(
{ userId },
process.env.JWT_SECRET,
{
expiresIn: '15m', // Short-lived
issuer: 'yourapp.com',
audience: 'yourapp.com'
}
);
}
// Verify tokens
function verifyToken(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET, {
issuer: 'yourapp.com',
audience: 'yourapp.com'
});
req.userId = decoded.userId;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid token' });
}
}
JWT Gotchas:
- Store secrets in environment variables
- Use short expiration times
- Can’t revoke tokens (use refresh tokens)
- Validate issuer and audience
- Don’t store sensitive data in payload (it’s base64, not encrypted)
Dependency Security
Your code might be secure, but what about your dependencies?
Automated Scanning
# Check for known vulnerabilities
npm audit
# Fix automatically (be careful)
npm audit fix
# Use Snyk for deeper analysis
npx snyk test
# Scan Docker images
docker scan yourimage:latest
Keep Dependencies Updated
// Use Dependabot or Renovate bot for automatic PRs
// Review and test updates regularly
// Lock dependency versions
// package-lock.json or yarn.lock
Minimize Dependencies
Every dependency is a potential vulnerability:
// Bad - heavyweight library for simple task
const _ = require('lodash');
const firstItem = _.first(array);
// Good - native JavaScript
const firstItem = array[0];
Security Headers
Essential headers that should be on every response:
const helmet = require('helmet');
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
scriptSrc: ["'self'"],
imgSrc: ["'self'", "data:", "https:"]
}
},
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true
}
}));
// Or set manually
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('X-XSS-Protection', '1; mode=block');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
next();
});
Security Checklist
Before deploying any application:
- Passwords hashed with bcrypt/Argon2 (cost factor 12+)
- HTTPS enforced with HSTS header
- SQL queries use parameterized statements
- User input validated and sanitized
- Output encoded based on context
- CSRF protection enabled
- Session management secure (httpOnly, secure, sameSite cookies)
- Rate limiting on authentication endpoints
- MFA available (required for admin)
- Security headers configured (CSP, X-Frame-Options, etc.)
- Dependencies scanned for vulnerabilities
- Secrets in environment variables (never in code)
- Error messages don’t leak sensitive info
- Logging excludes passwords and tokens
- Regular security audits scheduled
Conclusion
Security isn’t about implementing every possible protection—it’s about understanding the threats and implementing appropriate defenses. Most breaches happen because of basic mistakes: weak passwords, SQL injection, XSS, or outdated dependencies.
Fix the basics first. Then layer on additional security as needed. And remember: security is ongoing, not a one-time task. New vulnerabilities emerge constantly. Stay informed, stay vigilant, and never assume your application is “secure enough.”
Need a security audit for your application? VooStack offers comprehensive security assessments and remediation. Let’s talk about protecting your users.