Research

Authentication Bypass via HTTP Parameter Pollution

How duplicate query parameters can trick authentication middleware into granting access to protected resources — a look at the vulnerability, exploitation, and remediation.

HTTP Parameter Pollution (HPP) is a class of vulnerability that arises when a web application receives multiple parameters with the same name and its components disagree on which value takes precedence. The inconsistency between how different layers of the stack interpret duplicate parameters is often enough to bypass authentication checks.

This writeup covers a pattern I’ve seen in several real applications and a few CTF challenges.

The Setup

Consider an application where a middleware layer performs authorization, and a downstream handler performs the actual business logic. Both receive the same HTTP request, but they may parse it independently.

Normal authenticated request:

GET /api/admin/users HTTP/1.1
Host: app.example.com
Cookie: session=eyJhbGciOiJIUzI1NiJ9...

The middleware checks that the session corresponds to an admin user. If it does, the request passes through. If not, it returns 403.

The Vulnerability

The target application accepted query parameters to override certain behaviors. Specifically, it had a debug mode toggle intended for internal tooling:

GET /api/admin/users?debug=true HTTP/1.1
Host: app.example.com
Cookie: session=<low_privilege_session>

Internally, the middleware read the debug parameter from the query string and, when present and true, skipped certain validation steps for “development convenience.” It was never removed before the application shipped.

Exploitation

The interesting part wasn’t the debug bypass itself — it was that the application also had a WAF rule blocking requests containing debug=true from non-internal IP ranges.

The WAF parsed query strings using a different library than the application server. When fed duplicate parameters, the WAF took the first value and the app server took the last.

Payload:

GET /api/admin/users?debug=false&debug=true HTTP/1.1
Host: app.example.com
Cookie: session=<low_privilege_session>

The WAF saw debug=false (first value) and allowed the request through. The application server saw debug=true (last value) and bypassed the admin check.

Result: full access to the admin API with a standard user session.

HTTP/1.1 200 OK
Content-Type: application/json

{
  "users": [
    {"id": 1, "email": "admin@example.com", "role": "admin"},
    {"id": 2, "email": "user@example.com",  "role": "user"},
    ...
  ]
}

How Different Servers Handle Duplicate Parameters

The inconsistency is well-documented but still catches developers off guard:

Server / FrameworkBehavior with ?a=1&a=2
PHPLast value wins (a=2)
Node.js / ExpressFirst value wins (a=1)
ASP.NETComma-joined (a=1,2)
Flask / PythonFirst value wins (a=1)
Tomcat (Java)First value wins (a=1)
mod_rewrite (Apache)First value wins (a=1)

When your WAF and your app framework disagree, you have an HPP vector.

Chaining with Other Bugs

HPP becomes significantly more dangerous when combined with other vulnerabilities. A common chain:

  1. HPP bypasses the WAF or middleware check
  2. IDOR in the backend logic lets you access another user’s data
  3. Privilege escalation if the endpoint returns tokens or session material

Remediation

Short term: Remove the debug parameter from production code entirely. Feature flags should not live in query strings.

Structural fixes:

  • Normalize query parameters before they reach any decision-making code. If your middleware and your application handler each parse the query string independently, that’s where the inconsistency lives.
  • Don’t rely on WAF rules as the primary auth control. The WAF should be defense-in-depth, not the gatekeeper.
  • When building middleware that reads parameters for security decisions, be explicit about which value you want — first, last, or reject the request if duplicates exist.
from urllib.parse import parse_qs

def get_single_param(query_string: str, name: str) -> str | None:
    params = parse_qs(query_string, keep_blank_values=True)
    values = params.get(name, [])
    if len(values) != 1:
        return None  # reject ambiguous or missing params
    return values[0]

Returning None on duplicates and treating it as a missing parameter is the safest approach when the parameter is security-relevant.