March 26, 202610 min read

Web Security for Developers — OWASP Top 10 Explained

Practical guide to each OWASP Top 10 vulnerability with real code examples showing the vulnerability AND the fix. Covers XSS, SQL injection, CSRF, broken auth, SSRF, and more.

security owasp xss sql injection web
Ad 336x280

Security isn't something you bolt on after launch. Every vulnerability in the OWASP Top 10 exists because a developer made a reasonable-seeming decision that turned out to be exploitable. The fix is almost always simple -- but only if you know what to look for.

This covers the 2021 OWASP Top 10 (still the current standard as of 2026) with vulnerable code alongside the fix for each. Every example is something you'd actually encounter in a production codebase.

A01: Broken Access Control

The most common vulnerability. The server trusts the client to only request resources it should have access to.

Vulnerable -- direct object reference without authorization:
// Express route -- anyone can view any user's profile
app.get("/api/users/:id", async (req, res) => {
  const user = await db.users.findById(req.params.id);
  res.json(user); // includes email, address, payment info
});
Fixed -- verify ownership:
app.get("/api/users/:id", authenticate, async (req, res) => {
  // Only allow users to access their own data (or admins)
  if (req.user.id !== req.params.id && req.user.role !== "admin") {
    return res.status(403).json({ error: "Forbidden" });
  }
  const user = await db.users.findById(req.params.id);
  res.json(user);
});

Common broken access control patterns:

PatternRiskFix
/api/admin/users with no auth checkFull data exfiltrationMiddleware auth on all admin routes
Modifying user_id in POST bodyActing as another userUse session user ID, ignore body
/api/invoices/123 without ownership checkIDOR (Insecure Direct Object Reference)WHERE clause includes user_id
Client-side role checks onlyBypass by calling API directlyServer-side role validation
?role=admin in registration formPrivilege escalationIgnore role from client input

A02: Cryptographic Failures

Storing passwords in plaintext, using MD5/SHA1 for hashing, transmitting sensitive data over HTTP, or hardcoding encryption keys.

Vulnerable -- MD5 password hashing:
import hashlib

def store_password(password: str) -> str:
return hashlib.md5(password.encode()).hexdigest() # Cracked in seconds

Fixed -- bcrypt with salt:
import bcrypt

def store_password(password: str) -> bytes:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12))

def verify_password(password: str, hashed: bytes) -> bool:
return bcrypt.checkpw(password.encode(), hashed)

Why bcrypt and not SHA-256? SHA-256 is a fast hash -- that's a problem. GPUs can compute billions of SHA-256 hashes per second. Bcrypt is intentionally slow (the rounds parameter controls cost), making brute force attacks impractical.

AlgorithmHashes/sec (GPU)Time to crack 8-char password
MD5~40 billionSeconds
SHA-256~4 billionMinutes
bcrypt (cost 12)~10,000Centuries
Argon2id~1,000Heat death of the universe
Other cryptographic failures to watch for:
  • Sensitive data in URLs (/reset-password?token=abc123 gets logged in server access logs)
  • API keys committed to git (use environment variables)
  • JWT tokens with "alg": "none" accepted by the server

A03: Injection

SQL injection is the classic, but injection applies to any context where user input gets interpreted as code: SQL, NoSQL, LDAP, OS commands, XPath.

Vulnerable -- string concatenation in SQL:
// Express route
app.get("/api/search", async (req, res) => {
  const query = req.query.q;
  // NEVER DO THIS
  const results = await db.query(SELECT * FROM products WHERE name LIKE '%${query}%');
  res.json(results);
});
// Attack: ?q=' OR '1'='1' --
// Returns every row in the table
Fixed -- parameterized queries:
app.get("/api/search", async (req, res) => {
  const query = req.query.q;
  const results = await db.query(
    "SELECT * FROM products WHERE name LIKE $1",
    [%${query}%]
  );
  res.json(results);
});

With parameterized queries, the database treats the parameter as a literal string value, never as SQL syntax. The fix is that simple.

NoSQL injection (MongoDB):
// Vulnerable -- user controls the query operator
app.post("/api/login", async (req, res) => {
  const user = await db.users.findOne({
    username: req.body.username,
    password: req.body.password,
  });
});
// Attack body: { "username": "admin", "password": { "$ne": "" } }
// Matches any non-empty password -- bypasses auth
Fixed -- validate types:
app.post("/api/login", async (req, res) => {
  if (typeof req.body.username !== "string" || typeof req.body.password !== "string") {
    return res.status(400).json({ error: "Invalid input" });
  }
  // Now safe to query
});

A04: Insecure Design

This is about architectural flaws, not implementation bugs. No amount of input validation fixes a fundamentally insecure design.

Examples:


  • A password reset flow that emails the actual password (implies plaintext storage)

  • A "security question" system (answers are often public knowledge or guessable)

  • Rate-limiting login on the frontend but not the API

  • Allowing unlimited OTP attempts (brute force a 6-digit code in minutes)


Vulnerable -- no rate limit on OTP verification:

@app.post("/verify-otp")
async def verify_otp(phone: str, code: str):
    stored_code = redis.get(f"otp:{phone}")
    if stored_code == code:
        return {"status": "verified"}
    return {"status": "invalid"}
# 6-digit OTP = 1 million combinations
# At 100 requests/sec, cracked in ~2.7 hours
Fixed -- limit attempts and add lockout:
@app.post("/verify-otp")
@rate_limit(max_requests=5, window_seconds=300)
async def verify_otp(phone: str, code: str):
    attempts_key = f"otp_attempts:{phone}"
    attempts = int(redis.get(attempts_key) or 0)

if attempts >= 5:
return {"status": "locked", "message": "Too many attempts. Request a new code."}

redis.incr(attempts_key)
redis.expire(attempts_key, 300)

stored_code = redis.get(f"otp:{phone}")
if stored_code == code:
redis.delete(attempts_key)
return {"status": "verified"}

return {"status": "invalid", "remaining_attempts": 4 - attempts}

A05: Security Misconfiguration

Default credentials, unnecessary features enabled, overly verbose error messages, missing security headers.

Essential security headers (Express):
import helmet from "helmet";

app.use(helmet()); // Sets ~15 security headers with sane defaults

// Or manually:
app.use((req, res, next) => {
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("X-Frame-Options", "DENY");
res.setHeader("X-XSS-Protection", "0"); // Deprecated, CSP is better
res.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
res.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self'");
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
next();
});

Verbose error messages leak information:
// BAD -- tells attackers your stack
app.use((err, req, res, next) => {
  res.status(500).json({
    error: err.message,
    stack: err.stack,
    query: err.sql, // Exposes database schema
  });
});

// GOOD -- generic message, log details server-side
app.use((err, req, res, next) => {
console.error(err); // Full details in server logs only
res.status(500).json({ error: "Internal server error" });
});

A06: Vulnerable and Outdated Components

Using dependencies with known vulnerabilities. This is the one most teams ignore because updating is tedious.

# Check for known vulnerabilities
npm audit

# Check for outdated packages
npm outdated

# Automated fixes (be careful -- test after)
npm audit fix

Set up automated dependency scanning. GitHub's Dependabot, Snyk, or Socket.dev will flag vulnerable packages in PRs. The CodeUp engineering blog on codeup.dev has covered dependency management strategies in more depth.

A07: Identification and Authentication Failures

Weak passwords allowed, credential stuffing not prevented, session tokens in URLs, sessions that never expire.

Vulnerable -- no session expiry, no rotation:
app.post("/login", async (req, res) => {
  const user = await authenticate(req.body);
  if (user) {
    req.session.userId = user.id;
    // Session lives forever, token never changes
    res.json({ success: true });
  }
});
Fixed -- session configuration:
app.use(session({
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,     // JavaScript can't access
    secure: true,       // HTTPS only
    sameSite: "strict", // CSRF protection
    maxAge: 30  60  1000, // 30 minutes
  },
}));

app.post("/login", async (req, res) => {
const user = await authenticate(req.body);
if (user) {
req.session.regenerate((err) => { // New session ID post-login
req.session.userId = user.id;
res.json({ success: true });
});
}
});

Session regeneration after login prevents session fixation attacks, where an attacker sets a known session ID before the victim authenticates.

A08: Software and Data Integrity Failures

Trusting data from untrusted sources without verification. Insecure deserialization, unsigned updates, CI/CD pipeline compromise.

Vulnerable -- deserializing untrusted data (Python):
import pickle

# NEVER unpickle data from users
@app.post("/import")
async def import_data(file: UploadFile):
    data = pickle.loads(await file.read())  # Arbitrary code execution
    return {"imported": len(data)}
Fixed -- use safe formats:
import json

@app.post("/import")
async def import_data(file: UploadFile):
try:
data = json.loads(await file.read())
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="Invalid JSON")
# Validate structure with Pydantic
validated = [ImportItem(**item) for item in data]
return {"imported": len(validated)}

Also relevant: verifying integrity of downloaded files, using lockfiles (package-lock.json, poetry.lock) to prevent supply chain attacks, and enabling npm's --ignore-scripts for untrusted packages.

A09: Security Logging and Monitoring Failures

If you can't detect an attack, you can't respond to it. Most breaches go undetected for months.

What to log:
// Authentication events
logger.info("login_success", { userId: user.id, ip: req.ip });
logger.warn("login_failure", { username: req.body.username, ip: req.ip });
logger.warn("login_lockout", { username: req.body.username, ip: req.ip, attempts: count });

// Authorization failures
logger.warn("access_denied", { userId: req.user.id, resource: req.path, ip: req.ip });

// Input validation failures (potential probing)
logger.warn("invalid_input", { field: "email", value: "[redacted]", ip: req.ip });

// Rate limit triggers
logger.warn("rate_limit_exceeded", { ip: req.ip, endpoint: req.path });

What NOT to log:
  • Passwords (even failed ones)
  • Full credit card numbers
  • Session tokens
  • PII beyond what's needed for investigation
Set up alerts for anomalies: spike in 401/403 responses, login failures from a single IP, access to admin endpoints from unexpected IPs.

A10: Server-Side Request Forgery (SSRF)

The server makes HTTP requests on behalf of the user, and the user controls the URL. This lets attackers reach internal services that aren't exposed to the internet.

Vulnerable -- user-controlled URL fetch:
app.get("/api/preview", async (req, res) => {
  const url = req.query.url;
  const response = await fetch(url); // User controls this entirely
  const html = await response.text();
  res.json({ preview: extractTitle(html) });
});
// Attack: ?url=http://169.254.169.254/latest/meta-data/
// Returns AWS instance metadata including IAM credentials
Fixed -- URL validation and allowlist:
import { URL } from "url";
import dns from "dns/promises";

const BLOCKED_RANGES = ["10.", "172.16.", "192.168.", "169.254.", "127.", "0."];

async function isUrlSafe(urlString) {
let parsed;
try {
parsed = new URL(urlString);
} catch {
return false;
}

// Only allow HTTP(S)
if (!["http:", "https:"].includes(parsed.protocol)) return false;

// Resolve hostname to check for internal IPs
const { address } = await dns.lookup(parsed.hostname);
if (BLOCKED_RANGES.some((range) => address.startsWith(range))) return false;

return true;
}

app.get("/api/preview", async (req, res) => {
const url = req.query.url;
if (!(await isUrlSafe(url))) {
return res.status(400).json({ error: "URL not allowed" });
}
const response = await fetch(url, { redirect: "error" }); // Don't follow redirects
const html = await response.text();
res.json({ preview: extractTitle(html) });
});

The redirect: "error" is important -- without it, an attacker can use a redirect to bounce from an allowed external URL to an internal one, bypassing the check.

Security Checklist for Every Project

AreaCheckPriority
AuthenticationBcrypt/Argon2, session rotation, rate limitingCritical
AuthorizationServer-side checks on every endpointCritical
InputParameterized queries, type validationCritical
HeadersCSP, HSTS, X-Frame-Options via HelmetHigh
Dependenciesnpm audit, automated scanningHigh
LoggingAuth events, access denials, anomaly alertsHigh
HTTPSEnforce everywhere, HSTS preloadHigh
CookieshttpOnly, secure, sameSiteMedium
ErrorsGeneric user messages, detailed server logsMedium
SSRFURL validation, IP range blockingMedium
Security is a practice, not a feature. Every code review should include a quick mental check: "What happens if the user sends something I didn't expect here?" That instinct, more than any tool or framework, is what keeps applications secure.
Ad 728x90