Why Web Security Matters in 2025
Data breaches cost companies an average of 4.45 million dollars per incident. The OWASP Top 10 is the industry standard guide to the most critical web application security risks. Understanding these vulnerabilities and how to defend against them is not optional — it is a core requirement for every developer building for the web.
This guide covers all 10 OWASP vulnerabilities with real attack demonstrations and defense code you can implement today.
1. Broken Access Control (A01:2021)
Broken access control means users can act outside their intended permissions. This is the number one security risk on the OWASP list.
Attack Example: Insecure Direct Object Reference (IDOR)
// VULNERABLE — any logged-in user can view any other user's data
app.get("/api/users/:id/profile", authenticate, async (req, res) => {
const user = await db.query("SELECT * FROM users WHERE id = ?", [req.params.id]);
res.json(user); // No check if req.user.id === req.params.id!
});
// An attacker simply changes the URL:
// GET /api/users/1/profile (their own)
// GET /api/users/2/profile (someone else's — unauthorized access!)
// GET /api/users/3/profile (admin's data!)
Defense: Role-Based Access Control (RBAC)
// SECURE — check ownership and role
function authorize(...allowedRoles: string[]) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
if (!allowedRoles.includes(req.user.role)) {
return res.status(403).json({ error: "Forbidden" });
}
next();
};
}
// Check resource ownership
app.get("/api/users/:id/profile", authenticate, async (req, res) => {
const targetId = parseInt(req.params.id);
// Users can only access their own profile (admins can access any)
if (req.user.role !== "admin" && req.user.id !== targetId) {
return res.status(403).json({ error: "You can only view your own profile" });
}
const user = await db.query(
"SELECT id, name, email FROM users WHERE id = ?",
[targetId]
);
res.json(user);
});
// Admin-only route
app.delete("/api/users/:id", authenticate, authorize("admin"), async (req, res) => {
await db.query("DELETE FROM users WHERE id = ?", [req.params.id]);
res.status(204).send();
});
2. Cryptographic Failures (A02:2021)
Sensitive data must be protected both in transit and at rest. Never store passwords in plain text, never transmit data over HTTP, and never use weak hashing algorithms like MD5 or SHA-1 for passwords.
Hashing Passwords with bcrypt
import bcrypt from "bcryptjs";
// Hashing a password (during registration)
async function hashPassword(plainPassword: string): Promise<string> {
const saltRounds = 12; // Higher = more secure but slower
return bcrypt.hash(plainPassword, saltRounds);
}
// Verifying a password (during login)
async function verifyPassword(plainPassword: string, hashedPassword: string): Promise<boolean> {
return bcrypt.compare(plainPassword, hashedPassword);
}
// Registration
app.post("/api/register", async (req, res) => {
const { email, password } = req.body;
const hashedPassword = await hashPassword(password);
await db.query("INSERT INTO users (email, password) VALUES (?, ?)", [email, hashedPassword]);
res.status(201).json({ message: "User created" });
});
// Login
app.post("/api/login", async (req, res) => {
const { email, password } = req.body;
const user = await db.query("SELECT * FROM users WHERE email = ?", [email]);
if (!user || !(await verifyPassword(password, user.password))) {
// Use the same error message for both — prevents user enumeration
return res.status(401).json({ error: "Invalid email or password" });
}
const token = generateJWT(user);
res.json({ token });
});
HTTPS Enforcement
# Nginx — redirect all HTTP to HTTPS
server {
listen 80;
server_name myapp.com www.myapp.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name myapp.com;
ssl_certificate /etc/letsencrypt/live/myapp.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/myapp.com/privkey.pem;
# HSTS — force HTTPS for 1 year
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
}
3. Injection (A03:2021)
SQL Injection Demo and Prevention
// VULNERABLE PHP — SQL Injection
$username = $_POST['username'];
$query = "SELECT * FROM users WHERE username = '$username'";
// Attacker submits: ' OR 1=1 --
// Query becomes: SELECT * FROM users WHERE username = '' OR 1=1 --'
// Returns ALL users!
// SECURE PHP — Parameterized queries (PDO)
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = :username");
$stmt->execute(['username' => $_POST['username']]);
$user = $stmt->fetch();
// VULNERABLE Node.js — String concatenation
const query = `SELECT * FROM users WHERE email = '${req.body.email}'`;
db.query(query); // SQL Injection!
// SECURE Node.js — Parameterized queries
db.query("SELECT * FROM users WHERE email = ?", [req.body.email]);
// SECURE with an ORM (Prisma)
const user = await prisma.user.findUnique({ where: { email: req.body.email } });
# VULNERABLE Python
cursor.execute(f"SELECT * FROM users WHERE email = '{email}'") # SQL Injection!
# SECURE Python — Parameterized queries
cursor.execute("SELECT * FROM users WHERE email = %s", (email,))
# SECURE with Django ORM
User.objects.filter(email=email)
NoSQL Injection
// VULNERABLE MongoDB — NoSQL Injection
const user = await db.collection("users").findOne({
username: req.body.username,
password: req.body.password
});
// Attacker sends: { "username": "admin", "password": { "$ne": "" } }
// This matches any non-empty password!
// SECURE — validate input types
const { username, password } = req.body;
if (typeof username !== "string" || typeof password !== "string") {
return res.status(400).json({ error: "Invalid input types" });
}
const user = await db.collection("users").findOne({
username: String(username),
password: String(password) // Still hash passwords!
});
Command Injection
// VULNERABLE — command injection
const { exec } = require("child_process");
app.get("/api/ping", (req, res) => {
exec(`ping -c 3 ${req.query.host}`, (err, stdout) => { res.send(stdout); });
});
// Attacker: /api/ping?host=google.com;cat /etc/passwd
// SECURE — use execFile (does not spawn a shell) + validate input
const { execFile } = require("child_process");
const validator = require("validator");
app.get("/api/ping", (req, res) => {
const host = req.query.host;
if (!validator.isFQDN(host) && !validator.isIP(host)) {
return res.status(400).json({ error: "Invalid host" });
}
execFile("ping", ["-c", "3", host], (err, stdout) => { res.send(stdout); });
});
4. Insecure Design (A04:2021)
Insecure design refers to missing or ineffective security controls built into the application's architecture. This cannot be fixed by writing better code — it requires rethinking the design.
- Threat modeling: Identify threats before writing code using STRIDE or DREAD models
- Security user stories: "As a user, I should NOT be able to place more than 5 orders per minute"
- Business logic validation: If a coupon should only be used once, enforce it server-side
- Principle of least privilege: Give each component only the minimum access it needs
5. Security Misconfiguration (A05:2021)
Common Misconfigurations
# Check for exposed default pages
curl -I https://myapp.com/phpmyadmin # Should return 404
curl -I https://myapp.com/.env # Should return 403 or 404
curl -I https://myapp.com/.git/config # Should return 403 or 404
curl -I https://myapp.com/server-status # Should return 403 or 404
Security Headers with Helmet.js
npm install helmet
import helmet from "helmet";
app.use(helmet()); // Sets many security headers at once
// Or configure individually
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "https://cdn.example.com"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "https://api.example.com"],
fontSrc: ["'self'", "https://fonts.gstatic.com"],
objectSrc: ["'none'"],
upgradeInsecureRequests: [],
},
}));
app.use(helmet.hsts({ maxAge: 31536000, includeSubDomains: true }));
app.use(helmet.noSniff());
app.use(helmet.frameguard({ action: "deny" }));
app.use(helmet.xssFilter());
app.use(helmet.referrerPolicy({ policy: "strict-origin-when-cross-origin" }));
# Nginx security headers
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# Disable directory listing
autoindex off;
# Hide Nginx version
server_tokens off;
# Block access to sensitive files
location ~ /.(env|git|htaccess|htpasswd) {
deny all;
return 404;
}
6. Vulnerable and Outdated Components (A06:2021)
# npm audit — check for known vulnerabilities
npm audit
npm audit fix
npm audit fix --force # For breaking changes
# Use Snyk for deeper analysis
npm install -g snyk
snyk test
snyk monitor # Continuous monitoring
# Check for outdated packages
npm outdated
# Update all packages
npx npm-check-updates -u
npm install
# Python
pip install safety
safety check
pip-audit
# PHP
composer audit
# .github/dependabot.yml — automate dependency updates
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
reviewers:
- "security-team"
7. Identification and Authentication Failures (A07:2021)
Brute Force Protection
import rateLimit from "express-rate-limit";
// Rate limit login attempts
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts
message: { error: "Too many login attempts. Try again in 15 minutes." },
standardHeaders: true,
keyGenerator: (req) => req.body.email || req.ip, // Rate limit per email
});
app.post("/api/login", loginLimiter, loginHandler);
// Account lockout after repeated failures
async function loginHandler(req: Request, res: Response) {
const { email, password } = req.body;
const user = await db.query("SELECT * FROM users WHERE email = ?", [email]);
if (user && user.login_attempts >= 5 && user.locked_until > new Date()) {
return res.status(423).json({
error: `Account locked. Try again after ${user.locked_until.toISOString()}`
});
}
if (!user || !(await bcrypt.compare(password, user.password))) {
if (user) {
await db.query(
"UPDATE users SET login_attempts = login_attempts + 1, locked_until = IF(login_attempts >= 4, DATE_ADD(NOW(), INTERVAL 30 MINUTE), NULL) WHERE id = ?",
[user.id]
);
}
return res.status(401).json({ error: "Invalid credentials" });
}
// Reset attempts on successful login
await db.query("UPDATE users SET login_attempts = 0, locked_until = NULL WHERE id = ?", [user.id]);
const token = generateJWT(user);
res.json({ token });
}
Session Management Best Practices
import session from "express-session";
import RedisStore from "connect-redis";
import { createClient } from "redis";
const redisClient = createClient({ url: "redis://localhost:6379" });
await redisClient.connect();
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
cookie: {
secure: true, // Only send over HTTPS
httpOnly: true, // Prevent JavaScript access
sameSite: "strict", // Prevent CSRF
maxAge: 24 * 60 * 60 * 1000, // 24 hours
domain: ".myapp.com",
},
name: "__session", // Custom name (don't use default "connect.sid")
}));
8. Software and Data Integrity Failures (A08:2021)
Subresource Integrity (SRI) for CDN Scripts
<!-- Without SRI — if CDN is compromised, malicious code runs on your site -->
<script src="https://cdn.example.com/library.js"></script>
<!-- With SRI — browser verifies the file hash before executing -->
<script
src="https://cdn.example.com/library.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8w"
crossorigin="anonymous">
</script>
# Generate SRI hash
cat library.js | openssl dgst -sha384 -binary | openssl base64 -A
# Or use: https://www.srihash.org/
CI/CD Pipeline Security
# .github/workflows/security.yml
name: Security Checks
on: [push, pull_request]
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run npm audit
run: npm audit --audit-level=high
- name: Run Snyk
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
- name: Run SAST (Semgrep)
uses: returntocorp/semgrep-action@v1
with:
config: p/owasp-top-ten
9. Security Logging and Monitoring Failures (A09:2021)
What to Log
- All authentication events (login, logout, failed attempts)
- Authorization failures (403 errors)
- Input validation failures
- Application errors and exceptions
- Administrative actions (user creation, role changes)
- High-value transactions
Winston Logger Configuration
import winston from "winston";
const logger = winston.createLogger({
level: "info",
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
defaultMeta: { service: "myapp" },
transports: [
new winston.transports.File({ filename: "/var/log/myapp/error.log", level: "error" }),
new winston.transports.File({ filename: "/var/log/myapp/combined.log" }),
],
});
// Security event logging middleware
app.use((req, res, next) => {
res.on("finish", () => {
const logData = {
method: req.method,
path: req.path,
statusCode: res.statusCode,
ip: req.ip,
userId: req.user?.id || "anonymous",
userAgent: req.headers["user-agent"],
};
if (res.statusCode >= 400) {
logger.warn("Request failed", logData);
}
if (res.statusCode === 401 || res.statusCode === 403) {
logger.error("SECURITY: Unauthorized access attempt", logData);
}
});
next();
});
// Log authentication events
function logSecurityEvent(event: string, details: object) {
logger.warn(`SECURITY_EVENT: ${event}`, {
timestamp: new Date().toISOString(),
...details,
});
}
// Usage
logSecurityEvent("LOGIN_FAILED", { email: "admin@site.com", ip: "1.2.3.4", reason: "Invalid password" });
logSecurityEvent("ACCOUNT_LOCKED", { userId: 42, ip: "1.2.3.4", attempts: 5 });
logSecurityEvent("ROLE_CHANGED", { userId: 42, oldRole: "user", newRole: "admin", changedBy: 1 });
10. Server-Side Request Forgery — SSRF (A10:2021)
Attack Demo
// VULNERABLE — user controls the URL the server fetches
app.get("/api/fetch-url", async (req, res) => {
const url = req.query.url;
const response = await fetch(url); // SSRF!
const data = await response.text();
res.send(data);
});
// Attacker requests:
// /api/fetch-url?url=http://169.254.169.254/latest/meta-data/ (AWS metadata!)
// /api/fetch-url?url=http://localhost:6379/ (Internal Redis!)
// /api/fetch-url?url=file:///etc/passwd (Local files!)
Prevention
import { URL } from "url";
import dns from "dns/promises";
const ALLOWED_DOMAINS = ["api.example.com", "cdn.example.com"];
const BLOCKED_IPS = ["127.0.0.1", "0.0.0.0", "169.254.169.254", "::1"];
async function isUrlSafe(urlString: string): Promise<boolean> {
try {
const url = new URL(urlString);
// Block non-HTTP protocols
if (!["http:", "https:"].includes(url.protocol)) return false;
// Whitelist approach (preferred)
if (!ALLOWED_DOMAINS.includes(url.hostname)) return false;
// Resolve DNS and check for internal IPs
const addresses = await dns.resolve4(url.hostname);
for (const addr of addresses) {
if (BLOCKED_IPS.includes(addr) || addr.startsWith("10.") ||
addr.startsWith("172.16.") || addr.startsWith("192.168.")) {
return false;
}
}
return true;
} catch {
return false;
}
}
app.get("/api/fetch-url", async (req, res) => {
const url = req.query.url as string;
if (!(await isUrlSafe(url))) {
return res.status(403).json({ error: "URL not allowed" });
}
const response = await fetch(url, { redirect: "error" }); // Block redirects
const data = await response.text();
res.send(data);
});
Content Security Policy (CSP)
# Nginx CSP header
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com; connect-src 'self' https://api.example.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;
// Express CSP with helmet
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "https://cdn.jsdelivr.net"],
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "https://api.example.com"],
fontSrc: ["'self'", "https://fonts.gstatic.com"],
objectSrc: ["'none'"],
frameAncestors: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
upgradeInsecureRequests: [],
},
}));
CORS Explained with Examples
import cors from "cors";
// DANGEROUS — allows ALL origins
app.use(cors()); // Never use in production!
// SECURE — whitelist specific origins
const allowedOrigins = [
"https://myapp.com",
"https://www.myapp.com",
"https://admin.myapp.com",
];
app.use(cors({
origin: (origin, callback) => {
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error("Not allowed by CORS"));
}
},
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
allowedHeaders: ["Content-Type", "Authorization"],
credentials: true, // Allow cookies
maxAge: 86400, // Cache preflight for 24 hours
}));
Quick Reference: Security Checklist
| Category | Action | Priority |
|---|---|---|
| Access Control | Implement RBAC, check ownership on every request | Critical |
| Cryptography | Use bcrypt for passwords, HTTPS everywhere | Critical |
| Injection | Parameterized queries, validate all input | Critical |
| Authentication | Rate limit login, implement account lockout | Critical |
| Headers | Use helmet.js, set CSP, HSTS, X-Frame-Options | High |
| Dependencies | Run npm audit weekly, enable Dependabot | High |
| CORS | Whitelist specific origins only | High |
| Logging | Log auth events, 403s, and admin actions | High |
| SSRF | Whitelist URLs, block internal IPs | Medium |
| SRI | Add integrity attributes to CDN scripts | Medium |