0% прочитано

Nginx Trusted Headers: When Laravel Can Trust X-* Headers and When Clients Can Spoof Them

An HTTP header does not become trusted simply because its name starts with X-. This became important in ICanUp when Nginx started passing a GeoIP country code to Laravel. A client can send the same header itself, so the real security question is where the trust boundary lives and whether Nginx replaces client input before PHP sees it.

16 вересня 2026 р. 11 хв читанняNginx

When Laravel reads request()->header('X-Icanup-Country-Code'), it only sees an HTTP header value. Laravel itself does not know who created it: Nginx, a CDN, a corporate proxy, or the visitor's browser.

This became important in ICanUp after I moved visitor country resolution to Nginx and MaxMind.

I wanted the application to receive only a two-letter country code such as UA, without storing the visitor's raw IP address in Analytics.

That immediately raised another question: what prevents the client from sending the same header itself?

The X-* prefix does not make an HTTP header internal or trusted. By default, request headers are client-controlled input until the infrastructure establishes a separate trust boundary.
Client sends a forged X-Icanup-Country-Code header, Nginx overwrites it with a GeoIP value, and Laravel receives only the trusted internal value
A client can send its own X-* header. It becomes trustworthy only when Nginx does not pass that client value through and instead creates or replaces the value itself.

An HTTP Header Becomes a Security Concern When Laravel Starts Trusting It

Most HTTP headers are simply input data.

The problem starts when the application uses them for decisions with consequences: determining an IP address, country, HTTPS scheme, host, access policy, logging, or analytics.

At that point, the important question is no longer "does the header exist?" but "who is guaranteed to have set this value?"

Value source

Automatically trusted?

Why

Normal client HTTP request

No

The client controls its own headers

Nginx that computed the value itself

Yes, if client input is not passed through

Nginx is a controlled boundary before PHP

CDN or external reverse proxy

Not automatically

The origin must guarantee that only the trusted proxy can provide the authoritative value

A header named X-Internal-*

No

Naming does not create security

In My Case, the Header Carries Only a Country Code

For Public Analytics I needed the visitor country, but I did not want to store raw IP addresses or call an external GeoIP API for every page view.

Nginx therefore resolves the country locally through MaxMind, while Laravel receives only the normalized two-letter country code.

I described the GeoIP side separately in my article about GeoIP Analytics with Nginx, MaxMind, and a trusted country header.

The header name is configured through the application environment rather than hardcoded into the resolver.

ENV - Internal Analytics country header
ANALYTICS_COUNTRY_HEADER=X-Icanup-Country-Code

The Dangerous Assumption: Clients Do Not Know My Internal Header Name

Security cannot depend on that assumption.

Even if a header is undocumented, a user may discover it in code, configuration, logs, an article, or simply guess the name.

Any HTTP client can attempt to send it directly.

BASH - Client supplies the internal header itself
curl \  -H 'X-Icanup-Country-Code: US' \  https://icanup.com.ua/

If Laravel sees US simply because the client supplied it, the header is not trusted.

This is not a Laravel bug. Laravel received exactly what the infrastructure passed to PHP.

The Trust Boundary in My Setup Is Nginx

In my setup, Nginx sits in front of PHP-FPM. That makes it the right place to separate client input from internal values.

The key rule is simple: Laravel should receive the value created by Nginx, not the value from a client header with the same name.

Nginx - Do not treat the client value as trusted
# Unsafe:# the value still comes from the client. fastcgi_param HTTP_X_ICANUP_COUNTRY_CODE \    $http_x_icanup_country_code;
The $http_x_icanup_country_code variable represents a value received in the HTTP request. Using it as the internal source of truth gives the client control over the supposedly trusted value.
Nginx - Pass the value computed by Nginx to PHP
geoip2 /var/lib/GeoIP/GeoLite2-Country.mmdb {    auto_reload 1h;    $geoip2_country_code country iso_code;} location ~ \.php$ {    include fastcgi_params;     fastcgi_param HTTP_X_ICANUP_COUNTRY_CODE \        $geoip2_country_code;     fastcgi_pass unix:/run/php/php-fpm.sock;}

The GeoIP database path and PHP-FPM socket are examples here. The important part is the direction of data.

In the trusted version, HTTP_X_ICANUP_COUNTRY_CODE comes from Nginx GeoIP resolution. The client's X-Icanup-Country-Code is not the source of truth.

Replacing the Header Matters More Than Merely Adding One

If the client already supplied a header with the same name, the infrastructure should not leave an ambiguous mixture of client and internal values.

At the Nginx boundary I want one deterministic result.

Client supplied

Nginx resolved

Laravel should receive

nothing

UA

UA

US

UA

UA

DE

PL

PL

arbitrary text

unknown country

empty or a safe unknown value

The Same Principle Applies to X-Forwarded-For

The best-known example of the same problem is X-Forwarded-For.

Laravel often relies on proxy headers to determine the real client IP, HTTPS scheme, or host.

If the application blindly trusts X-Forwarded-For from any source, however, the client can send its own value.

Trust in forwarded headers must therefore be tied to trust in the proxy that created them.

Trusted Proxy Means a Trusted Node, Not a Trusted Header Name

Laravel trusted proxy configuration only makes sense when the application knows which proxies a request is expected to pass through.

The principle is straightforward:

if the request came through a known trusted proxy, Laravel can use forwarding headers created by that proxy. If a client can bypass the proxy and reach the origin directly, the trust boundary changes.

This is especially important with CDNs. A CDN header is trustworthy only when the origin is configured so a client cannot bypass the CDN and send the same header directly.

Direct Origin Access Can Break an Otherwise Correct Setup

Imagine a CDN adds X-Country-Code and Laravel trusts it.

If the production server is also directly reachable from the Internet, an attacker can bypass the CDN and send this to the origin:

BASH - Bypass the trusted CDN through direct origin access
curl \  -H 'X-Country-Code: US' \  https://ORIGIN_IP/

The security of that header therefore depends on more than Laravel configuration. It depends on network topology, firewall rules, Nginx, and who can actually reach the origin.

Laravel Does Not Resolve the Country Itself in My Setup

Once trust has been established at the Nginx layer, Laravel has a much smaller responsibility.

It does not analyze the IP address or perform another GeoIP lookup. It only reads the internal header, normalizes the value, and rejects anything that does not match the expected format.

PHP - Simplified country-code normalization contract
$header = config('analytics.country_header'); if (! is_string($header) || $header === '') {    return null;} $countryCode = strtoupper(    trim((string) $request->header($header))); return preg_match('/^[A-Z]{2}$/', $countryCode) === 1    ? $countryCode    : null;

In the real application, a dedicated Analytics resolver owns this responsibility. The important boundary is the same: Nginx determines the trusted value, Laravel validates its format.

Missing or Invalid Values Must Not Break the Page

GeoIP is not a critical dependency for rendering a public page.

If the country code is missing, the MaxMind database cannot resolve it, or the value is malformed, I do not want to return an HTTP 500.

Analytics simply records that view without the country dimension.

How I Verify That Spoofing Does Not Work

I test more than the normal request path. I also deliberately attempt to spoof the header.

A controlled development environment or temporary diagnostics can show the effective value after the request has passed through Nginx.

  • Send a normal request and record the value resolved by Nginx.
  • Repeat the request with a custom X-Icanup-Country-Code header.
  • Confirm that the client value does not reach Laravel as the trusted value.
  • Test an invalid value such as INVALID.
  • Test a request where GeoIP cannot determine a country.
  • Confirm that public rendering still works without a country code.
  • Confirm that Analytics stores only a normalized two-letter code.
  • Confirm that raw IP addresses do not appear in Analytics history.
BASH - Controlled header spoofing attempt
curl \  -H 'X-Icanup-Country-Code: US' \  https://dev.icanup.com.ua/

I do not keep a permanent public diagnostic route that exposes internal headers. Diagnostics should be temporary or limited to a controlled environment.

What I Do Not Do with Trusted Headers

  • Do not trust a header merely because it starts with X-.
  • Do not trust a value just because the name contains Internal or Trusted.
  • Do not pass $http_x_... into Laravel as an internal source of truth.
  • Do not trust forwarded headers without a defined trusted-proxy boundary.
  • Do not trust a CDN header if clients can bypass the CDN and reach the origin directly.
  • Do not use the country header for critical authorization decisions.
  • Do not store raw IP addresses in Analytics merely to determine a country.
  • Do not call an external GeoIP API for every page view.
  • Do not break public rendering when country resolution is unavailable.

My Short Checklist for an Internal X-* Header

  • Define exactly which component is allowed to create the header.
  • Do not use the client value as the internal value.
  • Create or replace the value explicitly at the Nginx boundary.
  • Check whether the origin is directly reachable.
  • For external proxies, limit trust to known proxy nodes or networks.
  • Validate the value format in Laravel even after a trusted Nginx boundary.
  • Provide safe behavior when optional data is missing.
  • Explicitly test spoofing with curl.
  • Do not use the header for a more critical decision than its trust model supports.

The header itself is not trusted. The path that produced its value is trusted.

What I Kept for Next Time

  • Treat every HTTP header as client input first.
  • Establish trust at the infrastructure boundary, not through the header name.
  • Create or replace internal values before passing them into PHP.
  • Treat Nginx, CDN, and Laravel as separate layers with separate responsibilities.
  • Always consider direct proxy bypass.
  • Normalize values in Laravel even after a trusted Nginx layer.
  • Test the malicious input path as well as the normal path.
  • Do not mix analytics signals with authorization decisions without a separate security model.

Conclusion

After adding GeoIP to ICanUp, I could have simply taught Laravel to read X-Icanup-Country-Code and stopped there.

That would have made the whole trust model depend on the assumption that clients would never send the same header themselves.

The safer design is different: Nginx resolves the country, creates the value passed to PHP, and Laravel only normalizes it and uses it as an optional analytics dimension.

The same principle applies to X-Forwarded-For, CDN headers, and other internal X-* values.

Laravel can trust a header only as much as it can trust the infrastructure path that created that header.