The OWASP Top 10 is the most widely used list of web application security risks. It is written as an awareness document: each category describes a class of risk, with examples and references. That is useful for training, less useful during a code review on a Tuesday afternoon, when the question is "what exactly should this pull request do, and how do we know it does it?".
This post turns each category of the OWASP Top 10:2025 into two things: one control we require in code, and one automated test that proves the control is there. It does not replace the OWASP documents, the ASVS, or a threat model. It is the minimum we check on every service. Examples are for a typical JSON API; the ideas apply to any stack.
A01: Broken Access Control
Still the top category. Users acting outside their intended permissions: reading other users' records, calling admin functions, changing IDs in URLs.
Control. Every query that returns or modifies user data is scoped by the authenticated principal, in one place (a repository method or query scope), never by an ID taken from the request alone. Deny by default for new routes.
Test. For each resource endpoint, a test with two users asserts that user B gets 404 (or 403) for user A's
resource:
@pytest.mark.parametrize("method,path", resource_routes())
def test_cannot_access_other_users_resource(client, user_a, user_b, method, path):
resource = create_resource_for(user_a, path)
r = client.as_user(user_b).request(method, path.format(id=resource.id))
assert r.status_code in (403, 404)
The route list comes from the router, so new endpoints are covered automatically.
A02: Security Misconfiguration
Default credentials, verbose errors, unnecessary features enabled, permissive CORS, missing security headers.
Control. Configuration is code: production settings live in version control, with debug off, a strict CORS allow list, and security headers set by the framework or the edge proxy.
Test. A smoke test against the deployed environment checks the headers and that errors do not leak stack traces:
curl -sI https://api.example.com/health | grep -iE "strict-transport-security|x-content-type-options|content-security-policy"
curl -s https://api.example.com/does-not-exist | grep -qi "traceback\|stack" && exit 1 || exit 0
A03: Software Supply Chain Failures
New and broadened in 2025, covering vulnerable and compromised dependencies, build systems and distribution.
Control. Lock files committed; dependencies from trusted registries only; automated update PRs; builds in CI from a clean environment, never from a developer laptop; images and artifacts pinned by digest.
Test. CI fails on known vulnerabilities above a severity threshold and on lock file drift:
- run: npm ci # fails if package-lock.json does not match package.json
- run: npm audit --audit-level=high
- run: trivy image --exit-code 1 --severity HIGH,CRITICAL "$IMAGE"
A04: Cryptographic Failures
Sensitive data in clear text, weak algorithms, home-made crypto, bad key handling.
Control. TLS everywhere, including internal traffic; passwords hashed with a slow, salted algorithm (Argon2id or bcrypt) through the framework; secrets from a secret manager; no custom crypto.
Test. A unit test asserts the password hash format and that the plain value is never stored:
def test_password_is_hashed_with_argon2(db, user_factory):
user = user_factory(password="correct horse battery staple")
stored = db.fetchval("SELECT password_hash FROM users WHERE id = %s", user.id)
assert stored.startswith("$argon2id$")
assert "correct horse" not in stored
A05: Injection
SQL, NoSQL, OS command, LDAP and template injection: untrusted input interpreted as code.
Control. Parameterised queries only; no string concatenation into SQL, shell commands or templates. Shell
calls use argument arrays, never shell=True.
Test. Static analysis in CI (for example Semgrep rules for string-formatted SQL and shell=True), plus a
regression test with classic payloads against search and login endpoints asserting normal error handling and no
extra rows:
@pytest.mark.parametrize("q", ["' OR '1'='1", "1; DROP TABLE users--", "\" OR \"\"=\""])
def test_search_treats_input_as_data(client, q):
r = client.get("/api/products", params={"q": q})
assert r.status_code == 200 and r.json()["total"] == 0
A06: Insecure Design
Flaws in the design itself, not in its implementation: missing rate limits on sensitive flows, business logic that can be abused, no limits on resource use.
Control. A short threat model for every new feature that touches money, identity or personal data, recorded in the design document, with abuse cases ("what if someone does this 10,000 times?").
Test. Abuse-case tests derived from the threat model, such as a limit on password reset requests:
def test_password_reset_is_rate_limited(client):
codes = [client.post("/api/password-reset", json={"email": "a@example.com"}).status_code
for _ in range(10)]
assert 429 in codes
A07: Authentication Failures
Credential stuffing, weak passwords, broken session handling, missing MFA for sensitive actions.
Control. Use the framework's or an identity provider's authentication, not custom code. Rate-limit by account and IP, check passwords against known-breached lists, rotate the session ID on login, and invalidate other sessions on password change.
Test.
def test_session_id_rotates_on_login(client, user):
before = client.get("/").cookies.get("session")
client.post("/login", data={"email": user.email, "password": "pw"})
assert client.cookies.get("session") != before
A08: Software or Data Integrity Failures
Trusting data or code without verifying integrity: unsigned updates, insecure deserialisation, webhooks accepted without signature checks.
Control. Verify signatures on everything that crosses a trust boundary: webhook payloads (HMAC with constant-time
comparison), uploaded packages, CI artifacts. Never deserialise untrusted data into native objects (pickle, Java
serialisation); use JSON with a schema.
Test.
def test_webhook_rejects_bad_signature(client):
r = client.post("/webhooks/payments", data=b'{"event":"paid"}',
headers={"X-Signature": "sha256=deadbeef"})
assert r.status_code == 401
A09: Security Logging and Alerting Failures
Attacks that go unnoticed because nothing was logged, or logs exist and nobody is alerted.
Control. Log security-relevant events (login success and failure, permission denials, admin actions, password and MFA changes) with user, time and source, without secrets or full personal data. Ship them centrally, and alert on patterns such as spikes of failed logins or denials.
Test. A test asserts that a denied access produces a structured audit event:
def test_denied_access_is_logged(client, user_b, resource_of_a, audit_log):
client.as_user(user_b).get(f"/api/invoices/{resource_of_a.id}")
assert audit_log.contains(event="access_denied", user_id=user_b.id, resource="invoice")
A10: Mishandling of Exceptional Conditions
New in 2025: failing open, leaking details in errors, inconsistent state after partial failures, unhandled edge cases.
Control. Fail closed: if an authorization check or a dependency it needs errors, deny. Catch specific exceptions, return generic error messages to clients, log details server-side, and use transactions so partial failures roll back.
Test. Simulate a failure in the authorization dependency and assert that access is denied, not granted:
def test_authz_fails_closed_when_policy_service_errors(client, user, monkeypatch):
monkeypatch.setattr(policy, "check", raise_(TimeoutError))
assert client.as_user(user).get("/api/admin/users").status_code in (403, 503)
Making it routine
A list only helps if it is used. What made this stick for our teams:
- The pull request template has one line per category with a checkbox-free "N/A or how it is handled" field for changes touching auth, data access or external input.
- The tests live in a shared security test suite that every service imports, parametrised by the service's own routes, so a new service gets them on day one.
- Static analysis and dependency scanning run on every pull request, not on a nightly job someone has to read.
- Each category maps to ASVS requirements for teams that need more depth, such as payment and identity services.
Summary
- Turn each OWASP category into a specific control in code and a test in CI.
- The highest-value tests are cross-user access tests generated from the route table.
- Supply chain controls (lock files, scanning, pinned digests) and failing closed are explicit categories in 2025.
- Treat the checklist as the floor, and use threat models and the ASVS for anything handling money or identity.
References: OWASP Top 10:2025, OWASP Application Security Verification Standard, OWASP Cheat Sheet Series, OWASP Password Storage Cheat Sheet.
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.