Why web applications are a big target

A web application is reachable by anyone on the internet, at any time, and it usually sits directly in front of valuable data: customer accounts, orders, medical records, invoices. Firewalls let its traffic through by design — port 443 must be open — so the application's own code is the last line of defense. Applications also change often, are built from many third-party components, and are written by people under deadline pressure. That combination makes them one of the most common ways into an organization.

The good news: most web weaknesses come from a small number of well-understood mistakes, and each has a well-understood fix. This page pairs every weakness with its fix.

The OWASP Top 10

The Open Worldwide Application Security Project (OWASP) is a nonprofit community that publishes free guidance on application security. Its best-known document, the OWASP Top 10, is an awareness list of the most critical web application security risks, based on data from real applications. The current edition is from 2025:

2025 category In plain language Main fix
A01 Broken Access Control Users can see or do things they should not; now also includes server-side request forgery (SSRF), where the server is tricked into fetching URLs chosen by an attacker Deny by default; check authorization on the server for every request; allowlist outgoing destinations
A02 Security Misconfiguration Default settings, open storage, verbose errors, unneeded features Hardened, repeatable configuration; review and scan it
A03 Software Supply Chain Failures Compromised or vulnerable libraries, build tools and pipelines, including components with known vulnerabilities Inventory components (a software bill of materials); scan, update and protect the build pipeline
A04 Cryptographic Failures Sensitive data unencrypted, or protected with weak or misused cryptography Transport Layer Security (TLS) everywhere, strong algorithms, proper password hashing
A05 Injection Input is treated as code: SQL, operating system commands and cross-site scripting Parameterized queries, safe application programming interfaces (APIs), output encoding
A06 Insecure Design The flaw is in the design itself, not in a coding slip Threat modeling and secure design patterns from the start
A07 Authentication Failures Weak logins and session handling Multi-factor authentication (MFA), rate limiting, secure session management
A08 Software or Data Integrity Failures Trusting updates, plugins or data without checking their integrity Signed updates, integrity checks on data you deserialize
A09 Security Logging and Alerting Failures Attacks happen unnoticed, or are logged but nobody is alerted Log security events and alert on them
A10 Mishandling of Exceptional Conditions Errors and unusual situations are handled badly: the application crashes, leaks details or fails open Handle every error explicitly, fail closed, show generic error messages

Injection

Injection happens when an application builds a command or query by gluing user input into it, so that cleverly chosen input changes the meaning of the command. SQL injection is the classic case: the input ends up inside a Structured Query Language (SQL) statement sent to the database. Depending on the query, an attacker may read data they should not see, change or delete it, or log in without a password. The same pattern exists for operating system commands, Lightweight Directory Access Protocol (LDAP) queries and template engines.

Seeing it, and fixing it

This self-contained example uses an in-memory SQLite database with three fake users. The unsafe function builds the query with an f-string; the safe one uses a parameterized query (also called a prepared statement), where ? is a placeholder and the value is passed separately:

import sqlite3

conn = sqlite3.connect(":memory:")  # a throwaway database in memory
conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)")
conn.executemany(
    "INSERT INTO users (name, email) VALUES (?, ?)",
    [
        ("Ada Jansen", "ada@example.com"),
        ("Bram de Vries", "bram@example.com"),
        ("Chen Li", "chen@example.com"),
    ],
)


def find_user_unsafe(name):
    # WRONG: the input becomes part of the SQL text
    query = f"SELECT id, name FROM users WHERE name = '{name}'"
    return conn.execute(query).fetchall()


def find_user_safe(name):
    # RIGHT: the ? placeholder sends the input as data, never as SQL
    query = "SELECT id, name FROM users WHERE name = ?"
    return conn.execute(query, (name,)).fetchall()


crafted = "x' OR '1'='1"
print("unsafe, normal: ", find_user_unsafe("Chen Li"))
print("unsafe, crafted:", find_user_unsafe(crafted))
print("safe, normal:   ", find_user_safe("Chen Li"))
print("safe, crafted:  ", find_user_safe(crafted))
unsafe, normal:  [(3, 'Chen Li')]
unsafe, crafted: [(1, 'Ada Jansen'), (2, 'Bram de Vries'), (3, 'Chen Li')]
safe, normal:    [(3, 'Chen Li')]
safe, crafted:   []

With the crafted input, the unsafe function produces the statement ... WHERE name = 'x' OR '1'='1'. The quote in the input closed the string early, and OR '1'='1' is true for every row — so every user is returned. The safe function sends the whole input as one value; the database looks for a user whose name is literally x' OR '1'='1, finds none, and returns an empty list. The structure of the query can no longer change.

The defenses, in order of importance:

  • Parameterized queries (or an ORM, object-relational mapper, that uses them) for every query that contains outside data. This is the fix; the rest are extra layers.
  • Least privilege for the database account: the web application's account should not be able to drop tables or read other applications' data.
  • Input validation and generic error messages, so the database's error text never reaches the user.

Input validation

Input validation checks that incoming data has the expected type, length, format and range before the application uses it: a quantity is a whole number from 1 to 99, a postal code matches the expected pattern, a country is one of a known list. Prefer allowlisting (describe what is allowed) over blocklisting (guess what is dangerous). Validation must happen on the server; checks in the browser improve the user experience but anyone can bypass them. Validation reduces the attack surface, but it does not replace parameterized queries or output encoding: a valid name can still contain a quote or an angle bracket.

Cross-site scripting

Cross-site scripting (XSS) happens when an application includes untrusted input in a web page without encoding it, so the victim's browser runs it as script. Because the script runs within the trusted site, it can read what the user sees, act as the user, or redirect them. There are three main kinds:

  • Stored: the input is saved (a product review, a profile field) and shown to every visitor later.
  • Reflected: the input comes from the request, such as a search term in the URL, and is echoed straight back.
  • DOM-based: the page's own JavaScript writes untrusted data into the page in the browser.

The fix: output encoding

The core defense is output encoding (escaping): convert characters that have a special meaning in HTML into harmless entities at the moment you write data into a page. Python's html.escape shows the idea:

import html

comment = "Great product! <script>alert('hi')</script>"

unsafe = "<p>" + comment + "</p>"
safe = "<p>" + html.escape(comment) + "</p>"

print(unsafe)
print(safe)
<p>Great product! <script>alert('hi')</script></p>
<p>Great product! &lt;script&gt;alert(&#x27;hi&#x27;)&lt;/script&gt;</p>

In the unsafe version a browser would see a real <script> element and run it. In the safe version it sees the text &lt;script&gt;, which it displays as the characters <script> — the review looks exactly as the user typed it, and nothing runs. In practice:

  • Use a template engine that escapes automatically (Jinja2, Django templates, React and most modern frameworks do), and be very careful with the features that switch escaping off.
  • Encode for the context: HTML text, HTML attributes, JavaScript and URLs each need their own encoding.
  • Add a Content Security Policy (see security headers) that forbids inline scripts, so a missed spot is much harder to exploit.
  • Mark session cookies HttpOnly, so scripts cannot read them.

Cross-site request forgery

Cross-site request forgery (CSRF) tricks a logged-in user's browser into sending a request the user never intended. The browser automatically attaches the user's cookies to requests for a site, so if a page on another site makes the browser submit a form to bank.example, the bank sees a valid session and may carry out the action. XSS abuses the user's trust in a site; CSRF abuses the site's trust in the user's browser.

The fixes:

  • Anti-CSRF tokens: every form includes a random, per-session value that another site cannot know; the server rejects requests without the correct token. Most web frameworks do this for you when it is switched on.
  • SameSite cookies: SameSite=Lax or Strict tells the browser not to send the cookie with requests started from other sites. Chromium-based browsers treat cookies without the attribute as Lax by default, but others do not, so set it explicitly.
  • Never change state with GET: actions such as "delete" or "transfer" use POST (or PUT/DELETE), and important actions ask for the password or a second factor again.

Broken access control

Broken access control means the application does not properly enforce what each user is allowed to do. It has been number one in the OWASP Top 10 since 2021. A common form is the insecure direct object reference (IDOR): a user opens /invoices/1001, changes the number to 1002, and sees someone else's invoice because the server only checked that the user was logged in, not that the invoice was theirs. Other forms: admin pages that are merely hidden from the menu, or an API that trusts a role=admin field sent by the client.

The fix is an explicit check, on the server, for every request: does this user own or have rights to this object?

invoices = {
    1001: {"owner": "ada", "amount": 49.95},
    1002: {"owner": "bram", "amount": 120.00},
}


def get_invoice(current_user, invoice_id):
    invoice = invoices.get(invoice_id)
    # Same answer for "does not exist" and "not yours": reveal nothing
    if invoice is None or invoice["owner"] != current_user:
        return "404 Not Found"
    return f"200 OK: invoice {invoice_id}, EUR {invoice['amount']:.2f}"


print(get_invoice("ada", 1001))
print(get_invoice("ada", 1002))  # Ada changes the number in the URL
print(get_invoice("ada", 9999))
200 OK: invoice 1001, EUR 49.95
404 Not Found
404 Not Found

current_user must come from the server-side session, never from a value the client sends. Deny by default, centralize these checks so no endpoint forgets them, and log refusals: many of them from one account is worth investigating. Access control models in general are covered in Access Control.

Security misconfiguration and headers

Many breaches need no clever attack at all, only a setting that was never changed. Typical examples:

  • Default credentials on an admin panel, database or appliance.
  • Verbose errors: stack traces, SQL errors or version numbers shown to users. Log the details on the server and show a generic message.
  • Open storage: a cloud bucket with customer files readable by anyone (see Cloud Security and Compliance).
  • Debug modes, sample applications, directory listings and admin interfaces left enabled on production servers.

Security headers

HTTP response headers let the server tell the browser to enforce extra protections. A sensible starting set:

Strict-Transport-Security: max-age=31536000; includeSubDomains
Content-Security-Policy: default-src 'self'; frame-ancestors 'none'
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Set-Cookie: session=7Qx…; Secure; HttpOnly; SameSite=Lax; Path=/
  • HTTP Strict Transport Security (HSTS): for the next year, the browser only connects to this site over HTTPS, so a network attacker cannot downgrade the connection to plain HTTP — after the first visit, or from the start if the domain is on the browsers' HSTS preload list (see TLS).
  • Content Security Policy (CSP): which sources the page may load scripts, styles and frames from. A strict policy blocks inline and third-party scripts, limiting the damage of XSS; frame-ancestors 'none' stops other sites from framing the page (clickjacking).
  • X-Content-Type-Options: nosniff: the browser must not guess file types.
  • Cookie flags: Secure (HTTPS only), HttpOnly (not readable by scripts) and SameSite (see CSRF).

Introduce a CSP carefully: a strict policy can break existing pages. The Content-Security-Policy-Report-Only header reports violations without blocking, so you can tune the policy first.

Authentication and session management

How to store passwords, use multi-factor authentication and support passkeys is covered in Passwords and Authentication. For a web application, what happens after the login matters just as much, because the session cookie is as good as the password while it is valid:

  • Session IDs are long and random, generated by the framework, and never placed in URLs.
  • Issue a new session ID at login (so an ID set before login cannot be reused) and invalidate it on the server at logout.
  • Expire idle sessions, and ask for re-authentication before sensitive actions such as changing the email address or payout account.
  • Limit login attempts and watch for credential stuffing; give the same message for "unknown user" and "wrong password".

Building security in

Fixing a weakness during design costs a conversation; fixing it in production costs an incident. A secure development lifecycle adds security activities to every phase:

  • Threat modeling during design: what are we building, what can go wrong, what will we do about it? Frameworks such as STRIDE (spoofing, tampering, repudiation, information disclosure, denial of service, elevation of privilege) help structure the discussion.
  • Secure coding standards and code review, with a reviewer who looks specifically at input handling, authorization and secrets.
  • Static application security testing (SAST): tools that analyze source code for risky patterns, such as string concatenation in SQL.
  • Dynamic application security testing (DAST): tools that probe the running application from outside, in a test environment.
  • Dependency scanning (software composition analysis): check every library against known vulnerabilities, keep a software bill of materials (SBOM), and update promptly — see Vulnerability Management.
  • Secret scanning in the repository and pipeline, so API keys and passwords never get committed.
  • Authorized penetration tests before major releases, and a clear way for outsiders to report what they find.

How a team runs these checks as part of normal work — SAST, SCA, DAST with ZAP, fuzzing and security regression tests — is on Security Testing.

Web application firewalls

A web application firewall (WAF) inspects HTTP requests and responses at the application layer and blocks those matching attack patterns, such as typical SQL injection or XSS input. It runs on a reverse proxy, a load balancer, a content delivery network or a cloud service; the OWASP Core Rule Set is a widely used open set of rules. A WAF is valuable for virtual patching — blocking a newly published vulnerability while the real fix is being built — and for stopping noisy automated attacks.