OWASP Top 10 Security Checklist (2021)
Work through all ten OWASP Top 10 categories with 45 concrete checks, plain-English explanations, and vulnerable-vs-fixed code for each. Progress is saved in your browser. Print it for an audit worksheet.
A01Broken Access Control
Access control enforces that users can only act within their intended permissions. When it breaks, attackers read or modify other users' data or reach admin functionality just by changing an ID or path in a request.
How to check: Log in as a low-privilege user and try to reach other users' records and admin routes by editing IDs and paths in URLs and API calls.
// Anyone can read any invoice by guessing IDs
app.get('/api/invoices/:id', async (req, res) => {
const inv = await db.invoices.findById(req.params.id);
res.json(inv);
});
app.get('/api/invoices/:id', requireAuth, async (req, res) => {
const inv = await db.invoices.findById(req.params.id);
if (!inv || inv.ownerId !== req.user.id) {
return res.status(404).end(); // don't reveal existence
}
res.json(inv);
});
A02Cryptographic Failures
This category covers sensitive data exposed because it wasn't protected properly — unencrypted in transit or at rest, or hashed with weak algorithms. A stolen database of fast, unsalted hashes like MD5 or SHA-1 can be cracked in hours.
How to check: Inventory sensitive data (passwords, tokens, PII) and verify each item is encrypted in transit via TLS, encrypted at rest, and — for passwords — stored with a slow, salted hash.
import hashlib
def store_password(pw):
# Fast + unsalted: crackable at billions of guesses/sec
return hashlib.md5(pw.encode()).hexdigest()
import bcrypt
def store_password(pw):
return bcrypt.hashpw(pw.encode(), bcrypt.gensalt(rounds=12))
def check_password(pw, stored):
return bcrypt.checkpw(pw.encode(), stored)
A03Injection
Injection happens when untrusted input is concatenated into a query or command and executed as code — SQL, NoSQL, OS commands, or LDAP. A single vulnerable parameter can expose or destroy an entire database.
How to check: Grep the codebase for string-built queries ("SELECT … " +, f-strings into SQL, eval/exec) and probe forms and API params with inputs like ' OR 1=1--.
// name = "'; DROP TABLE users;--" becomes part of the SQL
const q = "SELECT * FROM users WHERE name = '"
+ req.query.name + "'";
const rows = await db.query(q);
// Placeholder keeps input as data, never as code const rows = await db.query( 'SELECT * FROM users WHERE name = $1', [req.query.name] );
A04Insecure Design
These are flaws in the design itself rather than bugs in the code — missing rate limits, recovery flows that leak information, business logic that can be abused at scale. Perfect implementation can't fix a design that never considered abuse.
How to check: Threat-model each critical flow (signup, login, checkout, password reset) and ask "how would someone abuse this at scale, or out of order?"
// Unlimited attempts: credential stuffing at full speed
app.post('/login', async (req, res) => {
const ok = await verify(req.body.email, req.body.password);
res.json({ ok });
});
const rateLimit = require('express-rate-limit');
const limiter = rateLimit({ windowMs: 15 * 60 * 1000,
max: 10 });
app.post('/login', limiter, async (req, res) => {
const ok = await verify(req.body.email, req.body.password);
res.json({ ok });
});
A05Security Misconfiguration
Default credentials, debug mode left on, permissive CORS, verbose errors, and unnecessary features are among the most common findings in real-world assessments. Misconfiguration applies at every layer: framework, web server, database, and cloud.
How to check: Compare each environment's configuration against a hardening baseline, then hit the running app looking for stack traces, default pages, and exposed admin panels.
server {
listen 80;
autoindex on; # directory listing exposed
server_tokens on; # version leaked in headers
add_header Access-Control-Allow-Origin *;
}
server {
listen 443 ssl;
autoindex off;
server_tokens off;
add_header Strict-Transport-Security "max-age=63072000" always;
add_header X-Content-Type-Options nosniff always;
add_header Access-Control-Allow-Origin https://app.example.com always;
}
A06Vulnerable and Outdated Components
Your application inherits every vulnerability in the frameworks, libraries, and base images it ships with. Known CVEs in unpatched dependencies are one of the easiest ways in, because working exploits are public.
How to check: Run npm audit, pip-audit, or a scanner like Dependabot/Trivy against your dependency tree and container images, and review the reported CVEs.
# Unpinned base + loose install: ships whatever # CVEs happen to be in "latest" that day FROM node:latest COPY . . RUN npm install CMD ["node", "server.js"]
FROM node:22.12-alpine3.20
COPY package*.json ./
RUN npm ci --omit=dev && \
npm audit --audit-level=high
COPY . .
USER node
CMD ["node", "server.js"]
A07Identification and Authentication Failures
Weak login, session, and credential handling lets attackers become other users — through credential stuffing, brute force, or stolen and forged session tokens. Sessions and tokens need as much care as the passwords themselves.
How to check: Inspect session cookies for Secure/HttpOnly/SameSite flags, confirm tokens actually expire and are signature-verified, and test whether repeated failed logins get throttled.
// decode() does NOT verify the signature —
// anyone can forge an admin token
const jwt = require('jsonwebtoken');
app.use((req, res, next) => {
req.user = jwt.decode(req.cookies.token);
next();
});
const jwt = require('jsonwebtoken');
app.use((req, res, next) => {
try {
req.user = jwt.verify(req.cookies.token,
process.env.JWT_SECRET,
{ algorithms: ['HS256'], maxAge: '15m' });
next();
} catch { res.status(401).end(); }
});
A08Software and Data Integrity Failures
This is trusting code or data without verifying it: unsigned updates, CI pipelines that download and run scripts blindly, and unsafe deserialization of user-controlled objects. It was added in 2021 largely in response to supply-chain attacks.
How to check: Trace your build pipeline end-to-end and list every artifact fetched over the network — flag anything executed without a checksum, signature, or lockfile pin.
# Runs whatever the remote serves — no verification
steps:
- name: install tooling
run: curl -sL https://get.example.com/install.sh | bash
steps:
- name: install tooling
run: |
curl -sLo install.sh https://get.example.com/install.sh
echo "9f86d081884c7d65... install.sh" | sha256sum -c -
bash install.sh
A09Security Logging and Monitoring Failures
Breaches routinely go undetected for months because logins, failures, and high-value actions were never logged — or nobody watches the logs. Without an audit trail you can't detect an attack, respond to it, or reconstruct what happened.
How to check: Trigger failed logins and permission-denied errors in staging, then confirm they show up in logs with who/what/when context and would actually raise an alert.
def login(email, pw):
try:
return auth(email, pw)
except AuthError:
pass # silent failure: no trail, nothing to alert on
import logging
log = logging.getLogger("auth")
def login(email, pw, ip):
try:
return auth(email, pw)
except AuthError:
log.warning("login_failed email=%s ip=%s", email, ip)
raise
A10Server-Side Request Forgery (SSRF)
SSRF lets an attacker make your server fetch URLs of their choosing — including internal services and cloud metadata endpoints that are unreachable from the outside. It is a common way for servers to leak cloud credentials.
How to check: Find every place the server fetches a user-supplied URL (webhooks, importers, image proxies) and test each with http://169.254.169.254/ and internal hostnames.
// ?url=http://169.254.169.254/latest/meta-data/
// leaks cloud credentials
app.get('/preview', async (req, res) => {
const r = await fetch(req.query.url);
res.send(await r.text());
});
const ALLOWED = new Set(['img.example.com', 'cdn.example.com']);
app.get('/preview', async (req, res) => {
const u = new URL(req.query.url);
if (u.protocol !== 'https:' || !ALLOWED.has(u.hostname))
return res.status(400).send('URL not allowed');
const r = await fetch(u, { redirect: 'error' });
res.send(await r.text());
});
About this tool
The categories, ordering, and IDs follow the official OWASP Top 10 (2021) release. The explanations and 45 checklist items are a distilled, practical starting point drawn from OWASP's guidance — a working checklist for a code review or pre-launch pass, not a replacement for the full documentation or a professional penetration test.
Your progress is stored only in this browser's localStorage — there is no account, no server, and nothing about which boxes you tick ever leaves the page. Use your browser's print dialog to get a clean black-on-white worksheet.