Web security

OWASP Top 10 Explained with Code Examples for Developers

By the BugSafer Security Team · · 6 min read

The OWASP Top 10 is a regularly updated list of the most serious categories of web application security risk, published by the Open Worldwide Application Security Project. It is not a checklist of specific bugs but a grouping of risk areas—broken access control, injection, cryptographic failures, and so on—each illustrated with real-world patterns.

This article explains each category in plain language and pairs it with a short vulnerable snippet and its fixed version. The goal is recognition: once you can see the shape of a flaw in code, you can find it in your own codebase and fix it before someone else does.

The examples use common stacks (Node.js/Express, Python, SQL, and plain HTML/JS) because the underlying mistakes are language-agnostic. Adapt the fixes to your framework, but keep the principle.

Broken Access Control

Access control decides who is allowed to do what. Broken access control means the application lets a user reach data or actions they should not. It sits at the top of the list because it is both extremely common and extremely damaging—one missing check can expose every user's records.

A classic mistake is trusting an identifier from the request without verifying ownership:

// Vulnerable: any logged-in user can read any invoice by changing the id
app.get('/invoices/:id', requireLogin, async (req, res) => {
  const invoice = await db.invoice.findById(req.params.id);
  res.json(invoice);
});

The fix is to scope every query to the authenticated user (or check a role/permission explicitly):

// Fixed: the query itself enforces ownership
app.get('/invoices/:id', requireLogin, async (req, res) => {
  const invoice = await db.invoice.findOne({
    id: req.params.id,
    userId: req.user.id,
  });
  if (!invoice) return res.status(404).end();
  res.json(invoice);
});

Two habits prevent most of these bugs. First, deny by default: require an explicit permission check on every route rather than assuming a route is "internal." Second, enforce authorization on the server for every object access—never rely on the UI hiding a button. This flaw is often called IDOR (insecure direct object reference), and it is a subset of broken access control.

Injection (SQL, Command, and More)

Injection happens when untrusted input is interpreted as code or commands. SQL injection is the best-known form, but the same pattern appears in shell commands, LDAP queries, and template engines. The root cause is always string concatenation of data into an interpreter.

# Vulnerable: input becomes part of the SQL statement
query = "SELECT * FROM users WHERE email = '" + email + "'"
cursor.execute(query)

Use parameterized queries so the database treats input as data, never as SQL:

# Fixed: the driver binds the value safely
cursor.execute("SELECT * FROM users WHERE email = %s", (email,))

The same principle applies to shell commands. Avoid building command strings from input; pass arguments as a list and avoid the shell:

# Vulnerable
os.system("convert " + filename + " out.png")

# Fixed: no shell, arguments passed separately
subprocess.run(["convert", filename, "out.png"], check=True)

Parameterization is the primary defense. Input validation and allow-lists help, but they are a second layer—do not treat them as a substitute for safe query construction.

Cryptographic Failures

This category covers sensitive data that is exposed because it is not protected properly: passwords stored in plain text or weak hashes, data sent over HTTP, hardcoded keys, and weak algorithms. The fix is to use vetted libraries and current defaults rather than rolling your own.

# Vulnerable: fast, unsalted hash — trivially cracked
import hashlib
stored = hashlib.md5(password.encode()).hexdigest()

Use a purpose-built password hashing function with a per-user salt and a tunable work factor:

# Fixed: bcrypt/argon2 handle salting and cost
import bcrypt
stored = bcrypt.hashpw(password.encode(), bcrypt.gensalt())
# verify with bcrypt.checkpw(password.encode(), stored)

Beyond passwords: enforce HTTPS everywhere, store secrets in a secrets manager or environment configuration rather than source control, and use modern algorithms (AES-GCM for symmetric encryption, not ECB mode). If you find a fast hash like MD5 or SHA-1 protecting anything security-relevant, treat it as broken.

Security Misconfiguration

Misconfiguration is any insecure default or leftover setting: debug mode on in production, directory listing enabled, default credentials, permissive CORS, verbose error pages that leak stack traces, or unnecessary features left enabled. It is less about a single line of code and more about how the app is assembled and deployed.

// Vulnerable: reflects any origin and allows credentials
app.use(cors({ origin: '*', credentials: true }));
// Fixed: explicit allow-list of trusted origins
app.use(cors({
  origin: ['https://app.example.com'],
  credentials: true,
}));

Other common fixes: turn off debug/stack traces in production and return generic error messages, remove sample apps and default accounts, disable directory listing on the web server, and set security headers (Content-Security-Policy, X-Content-Type-Options, Strict-Transport-Security). Treat configuration as code—review it, version it, and check it in CI.

Vulnerable and Outdated Components

If you use a library with a known vulnerability, your application inherits it. This category is about knowing what you depend on and keeping it current. The practical work is dependency inventory plus automated scanning.

# Node.js: audit dependencies for known vulnerabilities
npm audit

# Python: check installed packages
pip-audit

Run these in CI so a vulnerable dependency fails the build rather than shipping silently. Also remove unused dependencies—every package you keep is attack surface you maintain. Pin versions for reproducibility, but schedule regular updates so pins do not become permanent. The same logic applies to your runtime, OS packages, and container base images.

Identification and Authentication Failures

This category covers weaknesses in how you confirm identity: weak password policies, missing brute-force protection, session IDs exposed in URLs, sessions that never expire, and broken "remember me" or password-reset flows. Authentication is easy to get subtly wrong, so lean on established libraries and frameworks.

// Vulnerable: predictable session value, no rotation on login
res.cookie('session', user.id);
// Fixed: framework session with rotation and secure flags
req.session.regenerate(() => {
  req.session.userId = user.id;
});
res.cookie('sid', req.sessionID, {
  httpOnly: true,
  secure: true,
  sameSite: 'lax',
});

Key practices: store passwords with a strong hash (see cryptographic failures), rate-limit login attempts, regenerate the session ID after login to prevent session fixation, set HttpOnly/Secure/SameSite on cookies, and implement multi-factor authentication for sensitive accounts. For password reset, use single-use tokens with a short expiry and never reveal whether an email exists.

Software and Data Integrity Failures, Logging, and SSRF

The remaining categories round out the list. Software and data integrity failures cover trusting code or data from untrusted sources—unsigned updates, insecure deserialization, or CI pipelines that pull dependencies without verification. The fix is to verify signatures and integrity (for example, lockfiles with hashes) and avoid deserializing untrusted input into objects.

Insufficient logging and monitoring means attacks go unnoticed. Log authentication events, access-control failures, and input validation errors—but never log passwords, tokens, or full card numbers. Make logs tamper-resistant and alert on anomalies.

Server-side request forgery (SSRF) happens when your server fetches a URL supplied by a user and can be tricked into reaching internal services:

# Vulnerable: fetches any URL, including internal metadata endpoints
requests.get(user_supplied_url)
# Fixed: allow-list of permitted hosts and schemes
from urllib.parse import urlparse
parsed = urlparse(user_supplied_url)
if parsed.scheme not in ("https",) or parsed.hostname not in ALLOWED_HOSTS:
    raise ValueError("blocked")
requests.get(user_supplied_url, timeout=5)

An allow-list of destinations is far more reliable than trying to block "bad" addresses, because internal ranges and DNS tricks are easy to miss.

Checklist

  • Enforce authorization on the server for every object access; scope queries to the current user.
  • Use parameterized queries and argument lists—never concatenate input into SQL or shell commands.
  • Hash passwords with bcrypt, scrypt, or Argon2; never MD5/SHA-1 for security purposes.
  • Serve everything over HTTPS and keep secrets out of source control.
  • Disable debug mode and stack traces in production; restrict CORS to known origins.
  • Run npm audit or pip-audit in CI and keep dependencies, runtimes, and images updated.
  • Regenerate sessions on login and set HttpOnly, Secure, and SameSite on cookies.
  • Rate-limit authentication and add multi-factor authentication for sensitive accounts.
  • Log security-relevant events without recording secrets, and alert on anomalies.
  • Allow-list outbound destinations for any server-side fetch of user-supplied URLs.

FAQ

Is the OWASP Top 10 a complete security checklist?

No. It is a awareness and prioritization document covering the most common and impactful risk categories, not an exhaustive list of every vulnerability class. Use it to focus effort, then supplement it with threat modeling, secure code review, and standards like the OWASP Application Security Verification Standard for deeper coverage.

How often does the OWASP Top 10 change?

It is revised periodically as the threat landscape shifts, with categories added, merged, or renamed between editions. The exact ordering and category names depend on the edition, so check which version you are referencing and map your controls to the current one rather than memorizing an old list.

Do these fixes apply if I use a framework?

Yes, but the mechanism differs. Frameworks often provide safe defaults—an ORM that parameterizes queries, session middleware that rotates IDs, templating that escapes output—so the work becomes using those features correctly and not bypassing them with raw queries or manual string building. The underlying principles are the same regardless of stack.

Scan your own code

BugSafer connects to your GitHub repositories with a read-only app and scans for vulnerabilities, secrets, vulnerable dependencies and misconfigurations — with prioritized, evidence-backed findings.

See how GitHub security scanning works →

Scan your first repository free

Connect GitHub and get prioritized, evidence-backed findings in minutes.

OWASP Top 10 Explained with Code Examples for Developers · BugSafer