Web Application Security for Developers — OWASP Top 10 Explained With Code (2026)
W
Writer
Published: 16 Jun 2026
13 min read
A practical guide to every OWASP Top 10 vulnerability — broken access control, SQL injection, cryptographic failures, and more — with vulnerable vs secure code examples in Node.js and Python.
Why Every Developer Needs to Know OWASP
The OWASP Top 10 is a standard awareness document for web application security. It represents the most critical security risks to web applications, agreed upon by security experts worldwide. It is not a checklist — it is a map of the ways real applications get compromised, with the most damaging and prevalent risks ranked first.
Most security vulnerabilities are not introduced by attackers — they are introduced by developers writing code under time pressure without security awareness. This guide covers each OWASP Top 10 category with concrete code examples showing the vulnerable pattern and the secure fix. Languages used are Node.js (Express) and Python (FastAPI), the two most common stacks for Indian developers building web APIs.
All vulnerable code examples in this article are intentionally simplified to illustrate specific weaknesses. They are for learning to recognise and fix vulnerabilities in your own code — not for exploitation of systems you do not own or have permission to test.
A01 — Broken Access Control
Broken access control is the number one web vulnerability category. It occurs when users can act outside their intended permissions — viewing another user's data, accessing admin functions, or modifying records they do not own. 94% of applications tested by OWASP had some form of broken access control.
Vulnerable Pattern — IDOR (Insecure Direct Object Reference)
javascript
Vulnerable: routes/invoices.js
// VULNERABLE: user can access any invoice by changing the ID
app.get('/api/invoices/:id', authenticate, async (req, res) => {
const invoice = await db.invoices.findById(req.params.id);
res.json(invoice); // No check that invoice belongs to req.user
});
javascript
Secure: routes/invoices.js
// SECURE: always scope queries to the authenticated user
app.get('/api/invoices/:id', authenticate, async (req, res) => {
const invoice = await db.invoices.findOne({
id: req.params.id,
userId: req.user.id// ownership enforced at DB query level
});
if (!invoice) return res.status(404).json({ error: 'Not found' });
res.json(invoice);
});
Rule: Always Add userId to Ownership Queries
Never fetch a resource by ID alone. Always add a userId (or orgId for multi-tenant apps) condition. If the record does not exist for that user, return 404 — not 403. Returning 403 reveals that the resource exists.
A02 — Cryptographic Failures
Cryptographic failures cover sensitive data exposure — storing passwords in plain text, using weak hashing algorithms, transmitting data over HTTP, and misconfiguring TLS. The root cause is almost always a developer making a convenience choice without understanding the security implication.
javascript
Vulnerable: Password Storage
// VULNERABLE: MD5 is cryptographically broken — rainbow tables existconst hash = crypto.createHash('md5').update(password).digest('hex');
await db.users.create({ email, passwordHash: hash });
// Also vulnerable: plain SHA-256 without saltconst hash2 = crypto.createHash('sha256').update(password).digest('hex');
Never use MD5 or SHA-1 for passwords — use bcrypt, Argon2, or scrypt
Always enforce HTTPS — redirect HTTP to HTTPS, set HSTS header
Do not store sensitive data you do not need — minimise PII collection
Use AES-256-GCM for encrypting data at rest (not AES-CBC — it lacks authentication)
Never hardcode secrets in source code — use environment variables or a secrets manager
A03 — Injection
Injection attacks occur when untrusted user data is sent to an interpreter as part of a command or query. SQL injection is the classic example, but the same pattern affects NoSQL, LDAP, OS commands, and template engines. The fix is always the same: never concatenate user input into a command — use parameterised queries or an ORM.
python
Vulnerable: SQL Injection (Python)
# VULNERABLE: direct string concatenation — attacker can inject SQL@app.get("/users")asyncdefget_user(username: str):
query = f"SELECT * FROM users WHERE username = '{username}'"# Attacker sends: username = ' OR '1'='1# Query becomes: SELECT * FROM users WHERE username = '' OR '1'='1'# Returns ALL users
result = await db.execute(query)
return result
python
Secure: Parameterised Query (Python)
# SECURE: parameterised query — user input never interpreted as SQL@app.get("/users")asyncdefget_user(username: str):
query = "SELECT * FROM users WHERE username = $1"
result = await db.fetchrow(query, username) # username passed separatelyreturn result
# Even better: use an ORM like SQLAlchemy or Prisma
user = await User.query.filter_by(username=username).first()
NoSQL Injection (MongoDB)
javascript
Vulnerable vs Secure: MongoDB
// VULNERABLE: user-supplied object passed directly to queryconst user = awaitUser.findOne(req.body);
// Attacker sends: { "password": { "$gt": "" } }// Matches any user where password is greater than empty string// SECURE: explicitly select only the fields you trustconst user = awaitUser.findOne({
email: req.body.email, // string onlypassword: req.body.password// string only
});
A04 — Insecure Design
Insecure design refers to missing or ineffective security controls at the architecture level — issues that code fixes cannot fully remediate. Examples include password reset flows that reveal whether an email exists, rate limits that are missing entirely, or business logic that can be abused at scale.
javascript
Vulnerable: Password Reset Reveals Email Existence
// VULNERABLE: different response reveals whether email is registered
app.post('/forgot-password', async (req, res) => {
const user = await db.users.findByEmail(req.body.email);
if (!user) {
return res.status(404).json({ error: 'Email not found' }); // reveals info
}
awaitsendResetEmail(user);
res.json({ message: 'Reset email sent' });
});
javascript
Secure: Consistent Response
// SECURE: always return the same response regardless of email existence
app.post('/forgot-password', async (req, res) => {
const user = await db.users.findByEmail(req.body.email);
if (user) {
awaitsendResetEmail(user); // only send if exists
}
// Same response either way — attacker learns nothing
res.json({ message: 'If that email exists, a reset link has been sent' });
});
A05 — Security Misconfiguration
Security misconfiguration is the most commonly found vulnerability. It includes default credentials unchanged, unnecessary features enabled, overly permissive CORS, missing security headers, and detailed error messages exposed in production.
javascript
Security Headers with Helmet.js (Express)
import helmet from'helmet';
import cors from'cors';
// SECURE: apply security headers to every response
app.use(helmet()); // Sets X-Frame-Options, X-XSS-Protection, HSTS, etc.// SECURE: restrict CORS to known origins only
app.use(cors({
origin: ['https://yourdomain.com', 'https://app.yourdomain.com'],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE']
// Never use origin: '*' for authenticated APIs
}));
// SECURE: hide framework version
app.disable('x-powered-by');
// SECURE: generic error responses in production
app.use((err, req, res, next) => {
console.error(err); // log full error internallyconst message = process.env.NODE_ENV === 'production'
? 'Internal server error'
: err.message; // detailed message only in dev
res.status(500).json({ error: message });
});
A06 — Vulnerable and Outdated Components
Using packages with known vulnerabilities is one of the easiest ways to get compromised. The Log4Shell vulnerability in 2021 affected millions of applications because one library had a critical RCE flaw and nobody knew it was in their dependency tree.
bash
Dependency Audit Commands
# Node.js — audit all dependencies for known CVEs
npm audit
# Auto-fix non-breaking vulnerabilities
npm audit fix
# Deeper check with more detail
npx better-npm-audit audit
# Python — check with pip-audit
pip install pip-audit
pip-audit
# GitHub Dependabot — enable in .github/dependabot.yml# Automatically opens PRs when vulnerabilities are found
Authentication failures include weak passwords allowed, missing brute-force protection, insecure session tokens, and missing multi-factor authentication. JWT misuse is a particularly common issue in modern APIs.
javascript
Vulnerable: JWT Verification
// VULNERABLE: 'none' algorithm attack// If the server accepts 'alg: none', attacker can forge any tokenconst decoded = jwt.verify(token, secret, {
// Missing: algorithms restriction
});
// VULNERABLE: secret stored in codeconstSECRET = 'mysecret123';
javascript
Secure: JWT Verification
// SECURE: explicitly restrict allowed algorithmsconst decoded = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'], // reject 'none' and other algsissuer: 'your-app',
audience: 'your-api'
});
// SECURE: rate limit login attemptsimport rateLimit from'express-rate-limit';
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutesmax: 10, // max 10 attempts per IPmessage: 'Too many login attempts — try again in 15 minutes'
});
app.post('/login', loginLimiter, loginHandler);
A08 — Software and Data Integrity Failures
This category covers CI/CD pipeline attacks and insecure deserialisation. A supply chain attack injects malicious code into a dependency before you install it. Insecure deserialisation runs attacker-controlled code when your app processes serialised objects.
Pin dependency versions in package.json — use exact versions, not ^ or ~, for security-sensitive packages
Use package-lock.json or yarn.lock and commit them — they lock the full dependency tree
Verify npm package checksums with npm ci (uses lockfile exactly, no resolution)
For CI/CD: require code review on pipeline config changes, never run untrusted code in privileged context
Avoid eval(), Function(), and unserialise() on user-supplied data
A09 — Security Logging and Monitoring Failures
Most breaches are not detected by the breached organisation — they are discovered by a third party weeks or months later. Missing logs mean missing evidence, missing alerting, and missing ability to contain damage. Logs must capture enough context to reconstruct what happened.
SSRF lets attackers make the server fetch URLs on their behalf — often used to reach internal services (metadata endpoints, internal APIs, databases) that are not exposed externally. It became prominent after attackers used it to extract AWS EC2 metadata credentials.
import { URL } from'url';
import dns from'dns/promises';
asyncfunctionisSafeUrl(urlString: string): Promise<boolean> {
try {
const url = newURL(urlString);
// Only allow http/httpsif (!['http:', 'https:'].includes(url.protocol)) returnfalse;
// Block private IP rangesconst addresses = await dns.resolve4(url.hostname);
for (const addr of addresses) {
if (isPrivateIP(addr)) returnfalse;
}
returntrue;
} catch {
returnfalse;
}
}
functionisPrivateIP(ip: string): boolean {
return/^(10.|172.(1[6-9]|2d|3[01]).|192.168.|127.|169.254.)/.test(ip);
}
app.post('/fetch-preview', async (req, res) => {
if (!awaitisSafeUrl(req.body.url)) {
return res.status(400).json({ error: 'URL not allowed' });
}
const response = awaitfetch(req.body.url);
const html = await response.text();
res.json({ preview: html });
});
Quick Reference — Security Checklist for Every PR
✓All DB queries scoped to authenticated user's ID (IDOR prevention)
✓User input never concatenated into SQL, shell commands, or template strings
✓Passwords hashed with bcrypt/Argon2 — never MD5/SHA-1
✓JWT verification explicitly restricts algorithms (no 'none')
✓Login endpoint rate-limited
✓CORS restricted to known origins — no wildcard on authenticated APIs
✓Security headers applied (Helmet.js or equivalent)
✓Error messages are generic in production (no stack traces exposed)
✓npm audit / pip-audit passes with no high/critical issues
✓Sensitive actions logged with user ID, IP, and timestamp
✓No secrets hardcoded — all from environment variables
Frequently Asked Questions
Do I need to know all of OWASP Top 10 before shipping a product?
Focus on A01 (access control), A03 (injection), and A02 (cryptography) first — these three cover the most damaging and common real-world breaches. The others are important but lower urgency for a new product. Add them to your security review checklist before launching to production users.
Is using an ORM enough to prevent SQL injection?
Almost — ORMs parameterise queries by default, which prevents the classic concatenation attack. But many ORMs allow raw query mode (Prisma's $queryRaw, Sequelize's query()). If you use raw queries anywhere, treat them with the same care as plain SQL and always pass user input as a separate parameter.
How do I test my application for OWASP vulnerabilities?
Start with OWASP ZAP (free, automated scanner) — it finds common issues in minutes. For deeper testing, use Burp Suite Community Edition for manual interception and testing. For Node.js specifically, run npm audit and eslint-plugin-security as part of your CI pipeline.
Is JWT secure for authentication?
JWT is a format, not a security guarantee. It is secure when: the secret is strong and stored in an environment variable, the algorithm is explicitly restricted (no 'none'), tokens have short expiry (15 min access + refresh token), and tokens are transmitted over HTTPS only. Many 'JWT vulnerabilities' are actually JWT misimplementation vulnerabilities.
Key Takeaways
A01 Broken Access Control: always scope DB queries to req.user.id — never trust the ID in the URL alone
A03 Injection: use parameterised queries or an ORM — never concatenate user input into SQL or shell commands
A02 Cryptographic Failures: bcrypt with 12 rounds for passwords, HTTPS everywhere, secrets in env vars
A05 Misconfiguration: apply Helmet.js, restrict CORS origins, hide error details in production
A06 Outdated Components: run npm audit weekly, enable Dependabot, use npm ci in CI pipelines
Build a security checklist for PR reviews — the best time to catch vulnerabilities is before code ships