Trusting a proxy is not one decision
A read counter behind a CDN was counting one read per post per half hour, for the whole internet. The one-line fix for that also mailed a working password-reset link to a domain the attacker chooses.

Published 2026-10-13 by Arif Hossen
Tags: Laravel, Security, Craft
Source: https://arifhossen.dev/blog/trusting-a-proxy-is-not-one-decision
The symptom
This site counts how many times a post has been read. The count is displayed nowhere yet. I built it now because read history is the one thing you cannot backfill. Wait until there is a page to show it on and the first year is simply gone.
The counter deduplicates: one address, one post, thirty minutes. Behind a CDN that turns out to mean one read per post per thirty minutes for the entire internet, because every request arrives from the CDN and $request->ip() returns the CDN's address for all of them. I would have spent a year collecting a number that measured nothing.
Laravel does not trust a proxy until you say so
Why
Laravel ignores X-Forwarded-For by default, and it is right to. Any client can send that header, so honouring it from an unknown source lets anyone claim to be anyone. A fresh application trusts no proxies at all, which means ip() gives you whatever opened the TCP connection. Behind a CDN, that is the CDN.
I checked rather than assumed.
// REMOTE_ADDR: 10.0.0.1
// X-Forwarded-For: 203.0.113.9
$request->ip(); // '10.0.0.1'
with no trusted proxies configured
The one-line fix is trustProxies(at: '*'). Trust every proxy, because the CDN's address set moves and pinning it is a maintenance job I would forget to do. That gets the counter a real visitor address.
It also does four other things.
One setting, five grants
Five headers
at: is the argument everybody reads about: which proxies do you believe. headers: is the one nobody mentions, and leaving it off takes the framework default, which is all of them.
- X-Forwarded-For: The client's address. This is what the read counter needs, and the only reason I turned any of this on.
- X-Forwarded-Host: The hostname the client asked for. Laravel builds every generated URL from it, including the one inside a password-reset email.
- X-Forwarded-Proto: http or https. The CDN terminates TLS, so without this every generated URL comes out http.
- X-Forwarded-Port: The port, for the same reason as proto.
- X-Forwarded-Prefix: A path prefix the proxy stripped before forwarding. Nothing here runs under one.
Four of the five are about how a URL gets assembled. Only the first is about who the visitor is, and the first was the only one I wanted.
A working reset token, delivered to a domain the attacker picked
What it opened
With the default headers, X-Forwarded-Host is trusted from any proxy, and under at: '*' any client is a proxy, because anything that reaches the origin qualifies. Laravel builds route() and url() from the host it believes it was asked for. So does the password-reset notification.
A POST to /forgot-password carrying X-Forwarded-Host: evil.example.com mails the victim a genuine reset link, with a valid token in it, pointing at the attacker's domain. The victim gets a correct-looking email from the right sender about an action they may well have just requested. This was measured end to end on the branch: the mailed URL came back with the attacker's host and a live token.
One line, three consequences.
| What changed |
Intended |
Effect |
ip() returns the forwarded address |
Yes |
The counter can tell two visitors apart. A client can also spoof the header and inflate its own count. |
url() and route() follow X-Forwarded-Host |
No |
A forged host on a forgot-password request mails a working reset token to the attacker's domain. |
| The login limiter keys on an address the client now chooses |
No |
Rotating the forwarded address hands out a fresh bucket on every attempt. |
I had accepted exactly one of those. Spoofable addresses inflating a read count is a real cost and a fair trade against counting nothing. I had not agreed to host injection, I would not have, and nothing in the change asked me.
Trust the header you need and no others
The fix
at: '*' stays. The address is what the counter needs and the CDN's ranges genuinely move. What changes is headers:.
$middleware->trustProxies(
at: '*',
headers: Request::HEADER_X_FORWARDED_FOR
| Request::HEADER_X_FORWARDED_PROTO
| Request::HEADER_X_FORWARDED_PORT,
);
bootstrap/app.php
Proto and port stay because the CDN terminates TLS, and dropping them makes every generated URL http on the wrong port, which breaks the same emails in a duller way. Host and prefix go.
Behind that sits trustHosts(), derived from APP_URL so it moves with the deployment, so that a plain forged Host header cannot stand in for the X-Forwarded-Host I just stopped trusting. That one is defence in depth and genuinely untested: Laravel's TrustHosts no-ops in the test environment, so no test can exercise it. The comment in the file says exactly that, rather than implying a coverage that does not exist.
The test that does hold asserts the mailed reset link rather than url(), because the mailed link is the actual exploit path. It goes red against the code that was on this branch for a day.
The limiter was keyed on a number the client now picks
The third one
Third consequence, and the one that took longest to admit. The login limiter keys on the submitted email plus $request->ip(). With every proxy trusted, the client picks the address, so rotating X-Forwarded-For hands out a fresh bucket every attempt. Twenty-five wrong passwords from twenty-five forwarded addresses: twenty-five redirects, no lockout.
It is worth being precise about what changed, because both states are bad in different directions. Before, the key was shared and unspoofable, so one attacker behind the CDN could lock every visitor out. After, it stopped existing. Neither of those is a rate limiter.
The fix is to key on something the client cannot rotate, the username, alongside the address-keyed limit rather than instead of it. That carries a cost I would rather state than have someone discover. A username-keyed limit is a targeted lockout by construction: anyone who knows an email address can spend requests to hold that account down. So its threshold is deliberately looser than the per-address one and its window short. It caps sustained guessing instead of eliminating it, and it bounds a burst to a lockout that clears itself. Mirroring the tight limit would make that lockout effectively permanent for the price of a few requests a minute.
[!CAUTION]
The real mitigation is one layer down: firewall the origin so only the CDN can reach it, and the spoofing question stops being the application's problem at all. The limiter is what the application can do on its own.
A config line is not a config line
What it cost
What I take from this is not "read the docs on trustProxies". I had read them. It is that a setting named for one thing, trust this proxy, can carry a bundle of unrelated grants, and the safe move is to enumerate what you are switching on rather than accept a default and find out which parts of it you needed later.
The sequence matters more than the fix. Trusting proxies fixed the counter. It broke password reset, which surfaced because I asked what else reads the request. It broke throttling, which surfaced because I asked the same question a second time after the first answer came back. Both questions were cheap. Not asking the second one would have merged a host-header injection into the branch that puts this site online.