OWASP Top 10 (2025): A Practical Guide for Every Backend Engineer
OWASP refreshed its Top 10 in late 2025, the first update since 2021, finalized in January 2026. The list is revised roughly every four years from accumulated breach data, so this edition will likely be the reference standard until 2029 or so.
If you build backend systems for a living, this list is not academic. It’s a map of how your application will most likely be breached, and in my experience, it’s one of the top interview questions for backend roles. Security is everyone’s business, but as a backend engineer you sit closest to the data, the money, and the trust boundaries, so you need to know these principles cold. These fundamentals can prevent the kinds of mistakes that lead to real-world breaches.
This guide is deliberately language-agnostic. The vulnerabilities are the same in Java, Go, Python, Node.js, C#, or PHP; only the framework names change. What’s new in 2025: two fresh categories (Software Supply Chain Failures at A03, Mishandling of Exceptional Conditions at A10), Security Misconfiguration jumping from #5 to #2, and SSRF absorbed into Broken Access Control.
Let’s go through all ten.
A01: Broken Access Control
Still number one, four editions running. Access control fails when your application authenticates a user correctly but never checks whether this user is allowed to touch this resource. The 2025 edition explicitly calls out BOLA (Broken Object Level Authorization) and BFLA (Broken Function Level Authorization), because API-heavy systems fail this way constantly.
The check belongs in the request path, every time:
User
↓
API (authenticated ✓)
↓
Authorization check: does THIS user own THIS resource?
↓
Database
The classic fintech version: GET /api/accounts/10023/transactions returns transactions for whoever’s ID is in the path, including accounts the caller doesn’t own. Change the number, read someone else’s statement.
I’ve seen this in the wild. Years ago I reviewed an API where every account lookup accepted an account ID straight from the URL. Authentication was flawless; authorization didn’t exist. Changing one digit exposed another customer’s data. It took less than five minutes to demonstrate, and exactly one extra condition in the database query to fix. I’ve never looked at a path variable the same way since.
Rule: Never trust IDs coming from the client.
The mitigation is to make ownership checks structural, not something each developer remembers to write. Every framework has a mechanism for this: declarative authorization rules, middleware, policy objects, guards. The pattern is the same everywhere: a deny-by-default posture where every route requires authentication unless explicitly opened, plus an ownership check that runs before the handler logic. The query itself should be scoped to the caller so that even a forgotten check fails safely:
// Vulnerable: trusts the ID in the URL
handler getTransactions(accountId):
return db.findTransactions(accountId)
// Safe: ownership is part of the query itself
handler getTransactions(accountId, currentUser):
account = db.findAccount(accountId, ownerId = currentUser.id)
if account is null: return 404
return db.findTransactions(account.id)
Notice that ownership is enforced by the query itself rather than checked afterwards. That eliminates an entire class of mistakes: there is no code path where the check can be forgotten. In Java, for example, Spring’s method security lets you make the same guarantee declarative:
@PreAuthorize("@accountGuard.owns(#accountId, authentication)")
@GetMapping("/api/accounts/{accountId}/transactions")
public Page<TransactionDto> transactions(@PathVariable Long accountId) { ... }
And never rely on the frontend hiding a button. The API is the boundary.
Since SSRF now lives under this category: if your service makes outbound calls to user-supplied URLs (webhook callbacks are the usual suspect in payment systems), validate the destination against an allowlist of hosts before your HTTP client ever dials out, and block private IP ranges and cloud metadata endpoints.
Tip: Be clear on the difference between Authentication (who you are) and Authorization (what you’re allowed to do). A01 is almost always an authorization failure, not an authentication one.
A02: Security Misconfiguration
Up from #5 to #2, and every “batteries included” framework user should feel personally addressed. Convenient defaults are wonderful in development and dangerous in production.
The recurring self-inflicted wounds: diagnostic and admin endpoints exposed to the internet (health dashboards, debug consoles, metrics endpoints that print environment variables, database passwords included), debug mode left on in production, verbose error pages leaking stack traces and framework versions, permissive CORS (* on an authenticated API), and default credentials on admin consoles.
The mitigation is discipline more than code. Know exactly which management endpoints your framework exposes and close everything you don’t need. Turn off debug mode and stack traces in production responses. Treat configuration as code: it goes through review, it differs per environment, and production secrets live in a secrets manager, never in a config file committed to Git. Then verify from the outside: hit your own production URL the way an attacker would and see what’s actually reachable.
Rule: If you didn’t deliberately expose it, assume an attacker has found it.
A03: Software Supply Chain Failures
New category, debuting at #3, and per OWASP’s data it has the highest incidence rate on the list. This is Log4Shell’s legacy. Your application is mostly other people’s code, and the trust chain is longer than it looks:
Your Service
↓
Package manager (Maven / npm / pip / NuGet)
↓
Direct dependency
↓
Transitive dependency
↓
Transitive dependency ← critical CVE lives here
A typical service pulls in hundreds of transitive dependencies, and any one of them can carry a critical CVE or, worse, be deliberately poisoned upstream.
Three practical moves, whatever your ecosystem. First, scan continuously: wire a dependency scanner (OWASP Dependency-Check, npm audit, pip-audit, Snyk, or your platform’s equivalent) into CI and fail the build on critical findings. Second, generate an SBOM (Software Bill of Materials) with a tool like CycloneDX, so that when the next Log4Shell drops, answering “are we affected?” takes minutes, not a panicked weekend of grepping. If you sell software to banks, expect auditors to start asking for this. Third, pin your versions with a lockfile and manage upgrades deliberately, because floating version ranges are how surprise code enters production.
A04: Cryptographic Failures
Down from #2 to #4, mostly because TLS adoption improved industry-wide, not because people stopped making mistakes. This category covers weak or missing encryption of data that deserves protection: passwords, card data, identity numbers, session tokens.
The rules that matter in any codebase: passwords are hashed with an adaptive algorithm (bcrypt, scrypt, or Argon2), never encrypted, and never plain MD5 or SHA-256. Every mainstream language has a maintained library for this; use it instead of composing primitives yourself:
// Wrong: fast hashes are built to be brute-forced
String hash = DigestUtils.sha256Hex(rawPassword);
// Right: adaptive, salted, deliberately slow
String hash = new BCryptPasswordEncoder(12).encode(rawPassword);
The difference is deliberate slowness: bcrypt makes every guess expensive, which turns a leaked password table from an instant credential dump into an impractical brute-force job.
Enforce TLS on every hop, including service-to-service traffic inside your network, because “internal” is not a security boundary. Encrypt sensitive fields at rest, keep keys in a KMS or vault rather than beside the data, and never write your own cipher wrapper. If you find DES, ECB mode, or a hardcoded IV in your codebase, that’s a finding, not a style preference.
A05: Injection
Injection slid from #3 to #5, but in a world where so much backend work still involves hand-written SQL against production databases, I refuse to relax about it. The vulnerability is the same as it’s always been: user input concatenated into a query or command.
The bad pattern, still alive in 2026, in every language:
query = "SELECT * FROM accounts WHERE account_number = '" + input + "'"
The fix is straightforward: parameterised queries, everywhere, always. Placeholders hand the input to the driver as pure data, so no quoting trick can ever change the query’s meaning. Every database driver and every ORM supports placeholders that keep data separate from query structure. Use them without exception, and treat string concatenation into a query as an automatic code-review rejection. The same principle extends beyond SQL: pass command arguments as arrays rather than shell strings, escape anything that ends up in an LDAP filter or XPath expression, and validate every inbound payload against a schema at the boundary as your first line of defence.
Rule: Data and query structure must never travel in the same string.
A06: Insecure Design
This one is uncomfortable because you can’t fix it with a library. Insecure design means the system was conceived without thinking about abuse: the flaw exists before a line of code is written. Security has to enter the pipeline at the very first stage:
Threat model
↓
Design
↓
Implementation
↓
Production
A transfer API that has no velocity limits, no idempotency, and no dual authorisation for large amounts can be implemented “correctly” in any language and still be a disaster waiting for an insider or a compromised credential.
The mitigation is process, applied at design time. For every money-moving flow I design now, I ask the same questions: What happens if this request is replayed? (Answer: idempotency keys, enforced with a unique constraint in the database.)
handler createTransfer(request):
existing = db.findTransferByIdempotencyKey(request.idempotencyKey)
if existing: return existing // replay returns the original result
return db.insertTransfer(request) // unique constraint on the key backs this up
Because the uniqueness lives in the database, even two replays arriving at the same instant can’t both succeed: the constraint wins the races that application code loses.
What happens if a valid user goes rogue? (Answer: transaction limits, maker-checker approval above a threshold.) What can an attacker do with just this one endpoint at 1,000 requests per second? (Answer: rate limiting and anomaly alerts.) Write the abuse cases into the ticket alongside the user stories. A one-hour threat modelling session on a new feature is the cheapest security work you will ever do.
A07: Authentication Failures
Holding steady at #7. This covers weak passwords, credential stuffing, broken session handling, and, increasingly, sloppy token implementations.
Start by leaning on your framework’s authentication machinery rather than rolling your own; mature frameworks ship session fixation protection, secure cookie handling, and CSRF defences by default, so the first mitigation is simply don’t disable them without understanding why. Add brute-force protection on the login path (throttle by username and by IP) and support MFA for anything administrative.
If you use JWTs, three rules apply regardless of language: validate the algorithm server-side and reject anything unexpected (the alg: none trick still catches people), keep access-token lifetimes short (minutes, not days) with rotation via refresh tokens, and treat token revocation as a design requirement. If your only logout mechanism is “wait for expiry”, you don’t have logout. And never build a password reset flow casually; it is an authentication endpoint with all the same stakes as login.
A08: Software and Data Integrity Failures
Steady at #8. This category is about trusting data and code without verifying it: insecure deserialization, unsigned artefacts, and CI/CD pipelines that can be tampered with.
Deserialization deserves special respect. Native object serialization (Java’s ObjectInputStream, Python’s pickle, PHP’s unserialize, .NET’s BinaryFormatter) turns attacker-controlled bytes into live objects, and in each of these ecosystems that has meant remote code execution in practice, not just theory. Never deserialize untrusted data with these mechanisms; use JSON or another data-only format with a strict schema, and if you need polymorphism, allow only a closed set of known types.
On the pipeline side: protect your main branch, require reviews, sign your artefacts and container images, and make sure the credentials your CI uses are scoped and rotated. An attacker who can write to your build pipeline owns every environment downstream of it.
Rule: Bytes from outside your trust boundary are data, never objects.
A09: Security Logging and Alerting Failures
Renamed from “Logging and Monitoring” to “Logging and Alerting”, and the rename is the lesson: logs nobody reacts to are storage costs, not security. This category stays on the list because breaches routinely run for months before detection, and when the forensics team arrives, the logs they need don’t exist.
Concretely: log every authentication decision (success, failure, lockout), every access-control denial, and every high-value business action (transfers, limit changes, privilege changes) as structured events with a correlation ID that follows the request across services. Then wire alerts to the patterns that matter: a spike in failed logins, access-denied errors from a single token, transactions just under an approval threshold.
I learned to take this seriously during an incident investigation. The question we needed to answer was simple: which session performed these actions? The logs couldn’t tell us, because nobody had thought denial events were worth recording. We ended up reconstructing the timeline from database timestamps. Proper audit logging and alerting went in shortly after; it should have gone in first.
The discipline that matters just as much is what you don’t log. Full card numbers, PINs, passwords, and raw identity numbers have no business in log files. Mask them at the logging layer itself, because “we’ll be careful” does not survive contact with a 2 a.m. debugging session. Your logs are themselves sensitive data; protect them accordingly.
A10: Mishandling of Exceptional Conditions
The second new category, and my favourite addition because it names something backend engineers have always known matters: what your system does when things go wrong is a security property. Failing open, swallowing exceptions, leaking internals in error responses, logic that behaves differently under abnormal input: it’s all here.
The cardinal rule is fail closed. If the fraud check throws, the transfer does not proceed:
// Fails open: an outage in the limit service approves everything
try { limitService.check(transfer); }
catch (Exception e) { log.warn("Limit check failed, continuing", e); }
// Fails closed
try { limitService.check(transfer); }
catch (Exception e) { throw new TransferRejectedException("Limit verification unavailable", e); }
The two versions differ by a single decision: what the system assumes when it doesn’t know. Secure systems assume “no”.
This category is personal for me. Early in my career I worked on a batch process that caught an exception mid-run, logged it, and carried on, quietly skipping a validation step for every record after the failure. The log line looked routine, so nobody noticed for days. The fix was one thrown exception; the lesson, that a swallowed error is a decision and not an accident, has stayed with me ever since.
Centralise error handling at one boundary so clients get a consistent, minimal payload: no stack traces, no SQL, no class names. Distinguish deliberately between what you tell the caller (little) and what you record internally (everything). And hunt down every empty catch block in your codebase; each one is a decision someone made to ignore a failure, usually without deciding anything at all.
Rule: When in doubt, fail closed. An outage is recoverable; an unauthorised transfer is not.
The Cheat Sheet
| Category | Biggest mistake | One habit that fixes it |
|---|---|---|
| A01 Broken Access Control | Missing ownership checks | Scope every query to the caller |
| A02 Security Misconfiguration | Debug/admin endpoints in production | Harden configs, verify from outside |
| A03 Supply Chain Failures | Unscanned, unpinned dependencies | Scan every build, keep an SBOM |
| A04 Cryptographic Failures | Fast hashes, DIY crypto | bcrypt/Argon2 + vetted libraries only |
| A05 Injection | Concatenating input into queries | Parameterise everything |
| A06 Insecure Design | No abuse cases at design time | Threat-model every new flow |
| A07 Authentication Failures | Rolled-your-own auth, eternal tokens | Framework defaults + short-lived tokens |
| A08 Integrity Failures | Deserializing untrusted bytes | Data-only formats, signed artefacts |
| A09 Logging & Alerting Failures | Logs without alerts (or with secrets) | Structured events + alerts, mask PII |
| A10 Exceptional Conditions | Failing open, empty catch blocks | Fail closed, centralise error handling |
Where to Start
Ten categories is a lot, so if you’re staring at an existing production estate, my honest prioritisation is this: fix your exposed diagnostics and error verbosity today (A02, it’s an afternoon of work), get dependency scanning into CI this sprint (A03), then audit your endpoints for object-level authorization (A01), because that’s where the highest-impact holes usually are. The rest you fold into your engineering standards so new code is born compliant.
Attackers don’t care what framework you use. They exploit the same mistakes over and over.
Secure software isn’t written by accident. It’s written by engineers who repeatedly make good security decisions, during design, during implementation, during deployment, and during maintenance. Learn these ten categories until they become habits.
That’s how secure systems are built.
The full official write-up lives at owasp.org/Top10/2025, worth a read.