skip to content

A Laravel voting app behind a load balancer logs the balancer's address as every voter's IP and builds http:// links; how do you fix it with trustProxies()?

level: seniorimportance: must knowfreq 50%

answer

  1. no proxy is trusted by default
  2. $request->ip() is REMOTE_ADDR until then
  3. trustProxies(at: ['10.0.0.0/8'])
  4. headers: Request::HEADER_FORWARDED for RFC 7239
  5. at: '*' trusts every caller

basics

~10 s

Laravel trusts no proxy by default, so $request->ip() and the scheme come from the balancer's connection. Configure $middleware->trustProxies(at: [...]) in bootstrap/app.php with the balancer's addresses or CIDR range so TrustProxies honours its X-Forwarded-* headers.

solid answer

~40 s

`TrustProxies` runs on the global stack, but with no configuration it trusts no proxy, so `$request->ip()` returns the balancer's address and `$request->secure()` sees plain HTTP from the balancer, which is why `url()` builds `http://` links. The fix goes in `bootstrap/app.php`: `$middleware->trustProxies(at: ['10.0.0.0/8'])` lists the balancer's addresses or ranges; after that, Laravel reads the client IP, scheme, host, port and prefix from `X-Forwarded-*` headers sent by those addresses. `trustProxies(headers: ...)` chooses which headers are trusted, for example `Request::HEADER_FORWARDED` for an RFC 7239 `Forwarded` header. `at: '*'` trusts every caller, which is acceptable only when the app cannot be reached except through the balancer. Otherwise a voter can send a forged `X-Forwarded-For` and bypass a one-vote-per-IP rule.

code

php · 15 lines
php
<?php

use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;

// bootstrap/app.php
->withMiddleware(function (Middleware $middleware): void {
    $middleware->trustProxies(
        at: ['10.0.0.0/8'],
        headers: Request::HEADER_X_FORWARDED_FOR
            | Request::HEADER_X_FORWARDED_HOST
            | Request::HEADER_X_FORWARDED_PORT
            | Request::HEADER_X_FORWARDED_PROTO,
    );
})

go deeper

for a junior

Recall that behind a load balancer $request->ip() shows the balancer until trustProxies() is configured in bootstrap/app.php.

for a middle

Explain the at: and headers: arguments, what each forwarded header changes, and how trustHosts() differs.

for a senior

Diagnose wrong IPs and schemes quickly, avoid '*' unless the network guarantees only the balancer can connect, and verify the fix with a forged header.

for a principal

Own the contract between the network edge and the app: which component sets forwarded headers, which addresses are trusted, and how that is tested on each change.

## The symptoms A voting app runs behind a load balancer that terminates TLS and forwards requests to the application servers over plain HTTP. Two things go wrong: - Every vote is logged, and rate limited, as coming from the same address: the balancer's. - Links generated with `url()` or `route()` start with `http://`, and some redirects bounce users off HTTPS. Both come from the same fact: Laravel sees the **connection** from the balancer, not the original client. ## What TrustProxies does, and its default `Illuminate\Http\Middleware\TrustProxies` is on the default global stack. On each request it tells the request object which proxy addresses are trusted and which forwarded headers to believe from them: - With **no configuration**, the list of trusted proxies is empty (apart from special-casing a few first-party hosting environments), so forwarded headers are ignored and `$request->ip()` is the TCP peer, `REMOTE_ADDR`. - The trusted **headers** default to `X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Port`, `X-Forwarded-Proto`, `X-Forwarded-Prefix` and the AWS ELB variant. ## The fix in bootstrap/app.php ```php ->withMiddleware(function (Middleware $middleware): void { $middleware->trustProxies(at: [ '10.0.0.0/8', ]); }) ``` The `at:` argument accepts: | Value | Meaning | |---|---| | An array of IPs and CIDR ranges | Trust only these addresses as proxies | | A comma-separated string | Same as an array, split and trimmed | | `'REMOTE_ADDR'` inside the list | Trust whichever address is connecting right now | | `'*'` | Trust every caller, whatever its address | Once the balancer is trusted, requests arriving **from it** have their forwarded headers applied: `$request->ip()` returns the voter's address, `$request->secure()` reflects `X-Forwarded-Proto: https`, and URL generation produces `https://` links with the public host. ## Choosing which headers to trust The `headers:` argument takes a bitmask of `Illuminate\Http\Request` constants: - The default set covers the common `X-Forwarded-*` headers. - `Request::HEADER_FORWARDED` switches to the standard `Forwarded` header from RFC 7239, for balancers that send that instead. - `Request::HEADER_X_FORWARDED_AWS_ELB` matches AWS Elastic Load Balancing's header set. - Trust only the headers your balancer actually sets; a header it passes through untouched is a header a client controls. ## Why '*' is dangerous for a voting app `trustProxies(at: '*')` is convenient when the balancer's addresses change, and the documentation offers it for cloud balancers. It is safe only if **nothing can reach the application servers except the balancer**. If a server is directly reachable, a voter can send: ``` X-Forwarded-For: 203.0.113.77 ``` and Laravel will believe it. Any rule keyed on `$request->ip()`, such as one vote per IP or a per-IP rate limit, becomes trivially bypassable. Firewalling the app servers so only the balancer can connect, or listing the balancer's real ranges, closes the hole. ## Laravel features that depend on the right IP and scheme Getting proxy trust right is not only about logs. Several framework features read the values it produces: - The `throttle` middleware identifies guest requests by `$request->ip()`, so before the fix every anonymous voter shares one rate-limit bucket. - `url()`, `route()` and redirects use the request's scheme and host, which is where the `http://` links came from. - Absolute signed URLs are checked against the incoming request's scheme and host, so a scheme mismatch behind the balancer makes valid signatures fail. ## Checking the fix 1. Log `$request->ip()`, `$request->getScheme()` and `$request->getHost()` from a test route behind the balancer. 2. Confirm the IP is the client's and the scheme is `https`. 3. From inside the network, send a request directly to an app server with a forged `X-Forwarded-For` and confirm it is **not** believed when the app server is not in the trusted list. ## A related configurator: trustHosts() `TrustHosts` is off by default and enabled with `$middleware->trustHosts()`. With no arguments it trusts the host of `APP_URL` and its subdomains; `at:` takes regular expressions or a closure, and `subdomains: false` narrows it. Requests with other `Host` headers are rejected. It is skipped in the `local` environment and while running unit tests, so it will not get in the way of development.

  • Why does trustProxies(at: '*') let a voter forge their IP when an app server is reachable directly?
    With `'*'` every caller is treated as a trusted proxy, so Laravel applies `X-Forwarded-For` from any connection. A voter who bypasses the balancer can put any address in that header, and `$request->ip()` returns it, defeating per-IP vote limits. Restrict network access or list the balancer's real ranges.
  • What does $middleware->trustHosts() do when called with no arguments in Laravel?
    It enables the `TrustHosts` middleware with a single trusted pattern: the host of `APP_URL` and any of its subdomains. Requests with other `Host` headers are rejected, except in the `local` environment and during unit tests, where the middleware does not enforce trusted hosts.

saying these in an interview costs you the question

  • Assumes Laravel trusts X-Forwarded-For by default
  • Uses trustProxies(at: '*') while app servers accept direct traffic
  • Believes forcing https URLs also fixes the logged client IP
  • Trusts X-Forwarded-Host from a balancer that passes it through untouched
  • Believes TrustHosts is enabled in the default global stack