ahmadrezaei / laravel-real-client-ip
Laravel middleware that restores the real client IP behind a CDN, load balancer or ingress controller that overwrites X-Forwarded-For, verified with a shared secret so the header cannot be spoofed.
Package info
github.com/ahmadrezaei/laravel-real-client-ip
pkg:composer/ahmadrezaei/laravel-real-client-ip
Requires
- php: ^8.2
- illuminate/contracts: ^12.0 || ^13.0
- illuminate/http: ^12.0 || ^13.0
- illuminate/support: ^12.0 || ^13.0
Requires (Dev)
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0 || ^11.0
- pestphp/pest: ^3.5 || ^4.0
README
Trust a CDN-supplied client IP header — but only for requests that also carry a matching shared origin token.
The problem
A CDN edge sits in front of your application. The edge knows the real client IP. Between the edge and your app sits an ingress controller (or a load balancer, or a service mesh) that overwrites the standard headers with its own peer address. From a live ingress-nginx configuration:
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr;
Note that this is proxy_set_header ... $remote_addr, not $proxy_add_x_forwarded_for. Whatever the edge put in
those headers is gone by the time the request reaches PHP, and $request->ip() reports the ingress pod, forever.
The fix is for the CDN to carry the address in a header the ingress does not manage — the same role CF-Connecting-IP
plays for Cloudflare. This package defaults to Mit-Connecting-IP, and any name works.
But a header the ingress does not manage is also a header the ingress does not sanitise. Anyone who reaches your
origin directly can set it. So the header is only believed when the request also presents a shared secret that the
CDN injects, in X-Origin-Token by default.
What it is, and what it is not
This is a trust gate, not access control:
| Request | Result |
|---|---|
| Valid token + valid IP header | $request->ip() returns the CDN-supplied address |
| Valid token + junk IP header | Served normally, the junk is discarded, peer address is used |
| Wrong or missing token | Served normally using the genuine peer address |
It fails open for availability and closed for trust. A request that cannot prove where it came from does not get blocked — it simply does not get to choose what its own IP address is. If you genuinely need the origin to refuse direct traffic, strict mode exists and is off by default.
The package is also a complete no-op until a token is configured, so it is safe to install, register and deploy before the secret exists.
Requirements
- PHP 8.2+
- Laravel 12 or 13
Laravel 11 is deliberately not in the constraint. Every published 11.x release is currently subject to unresolved
Packagist security advisories, so Composer refuses to install it under its default advisory policy — claiming support
for a version that cannot be installed would be a lie. The code itself has no Laravel 12-only API in it; if you need
11 and you are prepared to override the advisory policy, widening the constraint in a fork should be all it takes.
Installation
composer require ahmadrezaei/laravel-real-client-ip
The service provider is auto-discovered. Publish the config if you want to edit it directly:
php artisan vendor:publish --tag=cdn-client-ip-config
Configuration
Everything is env-driven. The minimum viable setup is one variable:
CDN_CLIENT_IP_TOKENS="a-long-random-shared-secret"
The full set:
CDN_CLIENT_IP_ENABLED=true # master switch CDN_CLIENT_IP_TOKENS= # one token, or several separated by commas CDN_CLIENT_IP_TOKEN_HEADER="X-Origin-Token" # header carrying the shared secret CDN_CLIENT_IP_HEADER="Mit-Connecting-IP" # header carrying the client address CDN_CLIENT_IP_MODE="remote_addr" # remote_addr | forwarded CDN_CLIENT_IP_REWRITE_FORWARDED=true # also rewrite X-Forwarded-For / X-Real-IP CDN_CLIENT_IP_ALLOW_PRIVATE=true # accept private and reserved ranges CDN_CLIENT_IP_STRIP_TOKEN_HEADER=true # hide the secret from the rest of the request CDN_CLIENT_IP_STRICT=false # reject requests without a valid token CDN_CLIENT_IP_STRICT_STATUS=403 CDN_CLIENT_IP_AUTO_REGISTER=true # prepend the middleware automatically
Generate a token with something like php -r 'echo bin2hex(random_bytes(32));'. Treat it as a credential.
Middleware registration and ordering
By default the middleware registers itself at the front of the global stack, ahead of TrustProxies and ahead of
everything else. That is deliberate, and it is the single most important detail in this package.
Middleware that runs before it sees the ingress address. Rate limiting, session handling, request logging,
TrustHosts, firewall packages and your own audit trail all read $request->ip(). If the address is fixed up halfway
down the stack, half your application keys off one IP and half off another — quietly, and only in production.
If you prefer to register it yourself, set CDN_CLIENT_IP_AUTO_REGISTER=false and use prepend():
// bootstrap/app.php ->withMiddleware(function (Middleware $middleware) { $middleware->prepend(\ManageIt\CdnClientIp\Http\Middleware\TrustCdnClientIp::class); })
prepend(), not append() and not web(). append() puts it behind TrustProxies and behind everything else that
has already made up its mind about the client IP. There is a test in this repository
(test_appending_it_leaves_earlier_middleware_looking_at_the_ingress) that exists purely to demonstrate that failure.
A cdn-client-ip route middleware alias is also registered, for the rare case where only a few routes need it.
Which mode should I use?
There are exactly two ways to make $request->ip() return the CDN-supplied address, and neither is free.
remote_addr (default)
Overwrites REMOTE_ADDR on the request's server bag, and — because rewrite_forwarded_headers defaults to true —
also rewrites X-Forwarded-For and X-Real-IP with the same address.
- Works no matter how the host application configures trusted proxies, including not configuring them at all.
- Sidesteps Symfony's own trusted-proxy model rather than working through it.
The forwarded-header rewrite is not decoration. Symfony resolves the client IP like this:
$ip = $this->server->get('REMOTE_ADDR'); if (! $this->isFromTrustedProxy()) { return [$ip]; } return $this->getTrustedValues(self::HEADER_X_FORWARDED_FOR, $ip) ?: [$ip];
If the app trusts its proxies (TrustProxies::at('*'), which is extremely common) then X-Forwarded-For wins over
REMOTE_ADDR. Setting REMOTE_ADDR alone would be silently beaten by the ingress-written X-Forwarded-For. With the
rewrite on, both resolution paths agree and the answer is the same either way. Turning
CDN_CLIENT_IP_REWRITE_FORWARDED off re-introduces exactly that bug; there is a test pinning that down too.
forwarded
Rewrites X-Forwarded-For and X-Real-IP only, and lets Symfony's existing trusted-proxy machinery resolve them.
REMOTE_ADDR is never touched.
- Framework-native: the client IP is still resolved by the mechanism Laravel documents.
- It does nothing at all unless the app already trusts the immediate peer. With no trusted proxies configured,
isFromTrustedProxy()is false, Symfony never looks at the header, and$request->ip()keeps returning the ingress address. No error, no warning — the middleware runs, rewrites the header, and has no effect.
Why remote_addr is the default
Because this package gets installed into applications whose proxy configuration the package author cannot see. A
default that works everywhere and is slightly impure beats a default that is pure and silently broken in an unknown
fraction of installations. The failure mode of forwarded mode — "I installed it, nothing changed, there is nothing
in the logs" — is the worst kind.
Choose forwarded if your app already has a deliberate, correct TrustProxies configuration and you would rather keep
one single mechanism resolving client IPs. Otherwise leave the default alone.
| app trusts proxies | app trusts nothing | |
|---|---|---|
remote_addr (default) |
works | works |
remote_addr, rewrite off |
broken (ingress XFF wins) | works |
forwarded |
works | silent no-op |
The CDN side
The edge must set both headers on every proxied request. For nginx:
location / { proxy_set_header Mit-Connecting-IP $remote_addr; proxy_set_header X-Origin-Token "a-long-random-shared-secret"; proxy_pass https://origin.example.com; }
proxy_set_header replaces any client-supplied value, which is the property the whole design depends on — see the
security notes. For Cloudflare Workers or a similar edge, set the same two headers on the outbound fetch, and delete
any inbound copy first.
Strict mode
CDN_CLIENT_IP_STRICT=true
Every request without a valid token is rejected with 403 (configurable via CDN_CLIENT_IP_STRICT_STATUS and
CDN_CLIENT_IP_STRICT_MESSAGE). This turns the trust gate into access control and should be a deliberate decision:
the origin stops answering the moment the CDN's token drifts, a certificate probe hits it, or a health check forgets
the header.
Strict mode is inert while no token is configured. A missing secret can never take the origin offline.
Security notes
The origin connection must be HTTPS. The token travels in a request header on every single request. Over plaintext HTTP anyone on the path can read it, replay it, and forge any client IP they like. If the CDN-to-origin hop is not TLS, this package provides no security at all — it just moves the spoofable header around.
This is a shared secret, not mTLS. Anyone who can administer the CDN configuration for the domain can read the token, and so can anyone who can read the origin's environment. There is no per-request signature, no nonce and no replay protection: a captured token is valid until it is rotated. If your threat model needs cryptographic proof of origin, use mutual TLS or a signed, time-bounded header — not this.
Only enable this if the CDN overwrites the client-IP header on every proxied request. If the edge merely appends
to it, or passes a client-supplied value through, then a client can put anything it likes in Mit-Connecting-IP and
the token is the only thing between an attacker and a forged IP in your logs, your rate limiter, your geo-blocking
and your audit trail. Verify with a request that sets the header itself:
curl -H 'Mit-Connecting-IP: 1.2.3.4' https://your-site.example/
The address your application records must not be 1.2.3.4.
The address is always validated. Only something FILTER_VALIDATE_IP accepts (IPv4 or IPv6) is ever trusted, so
even a leaked token cannot inject newlines, hostnames, CIDR ranges or SQL-ish payloads into your logs, rate-limiter
keys or audit trails. Set CDN_CLIENT_IP_ALLOW_PRIVATE=false to additionally reject private and reserved ranges.
The token is removed from the request once it has been checked (CDN_CLIENT_IP_STRIP_TOKEN_HEADER), so it does not
turn up in exception reports, debug toolbars or request dumps. It can still appear in your web server's access logs if
you log request headers — do not.
Rotating the token
tokens accepts a list, so old and new are valid at the same time and there is no window where requests are
unauthenticated:
- Add the new token alongside the old one on the origin, and deploy:
CDN_CLIENT_IP_TOKENS="new-secret,old-secret"
- Switch the CDN to send the new token, and let the change propagate to every edge node.
- Confirm traffic still resolves real client IPs.
- Drop the old token on the origin, and deploy:
CDN_CLIENT_IP_TOKENS="new-secret"
Comparison is timing-safe (hash_equals) and does not short-circuit on the first match, so the number of configured
tokens is not observable through response timing.
Testing
composer install
composer test
The suite runs against Laravel 12 and 13 on PHP 8.2–8.4, at both lowest and highest resolvable dependencies, in CI.
License
MIT. See LICENSE.