EST. 2026 — Notes from real server work SYSADMIN · SECURITY · AI · DIRECTADMIN Issue 01
THE SYSADMIN ALMANAC
Home / DirectAdmin / Laravel Behind a Proxy Sees 127.0.0.1 — Silent Form, No Error
DirectAdmin

Laravel Behind a Proxy Sees 127.0.0.1 — Silent Form, No Error

by Ton-Or ·16 Aug 2026·9 min read
Laravel Behind a Proxy Sees 127.0.0.1 — Silent Form, No Error

The hardest bugs to find are the ones where every layer says everything is fine — the access log returns a clean 302, the mail log is empty, and the app log has no warnings, even though the user submits the form and nothing happens

Someone reported that submitting the contact form on a Laravel site brought up a message saying verification had failed and to try again. When I tried it myself, it happened intermittently, but one thing was certain: not a single email ever went out. At first I thought it was an SMTP issue because the site was on DirectAdmin and had just moved to a new server, but after tracing it through, it turned out to have nothing to do with mail at all. The cause was that the app saw every visitor's IP address as 127.0.0.1.

I think this case is worth writing about because it teaches two things at once — how to trace a bug when every log says everything is fine, and the trap in fixing it that leads many people to leave a security hole open without realizing it.

All domain names and IP addresses in this article are examples.

How many layers does a request pass through on this kind of host?

First, I need a picture of what handles a request before it reaches the code. On DirectAdmin with a web stack that puts nginx in front of apache, with a CDN adding another layer, it looks like this.

Layers the request passes throughClient203.0.113.45→CDNAdds CF-Connecting-IP→nginxAppends to X-Forwarded-For→apacheForwards via 127.0.0.1→PHP-FPMREMOTE_ADDR = 127.0.0.1

That last layer is the heart of the problem. PHP sees only the peer connecting directly to it, which is the proxy on the same machine. The actual visitor's IP is forwarded in headers (X-Forwarded-For, CF-Connecting-IP), not in REMOTE_ADDR, and Laravel 11/12 will not touch those headers until TrustProxies is configured. That is the correct default, because headers can be forged. The principle is that the framework trusts nothing until the administrator tells it whom it can trust.

The problem is that most app developers do not know they need to tell it, and $request->ip() never throws an exception. It simply returns 127.0.0.1 as though that were the right answer.

Diagnosis: working from the outside in, and finding silence

What made this case take time was that every point that should have complained stayed quiet. I checked in this order.

  1. The domain's access log — POST /contact returned 302, meaning the request really did reach the app, and the app redirected back to the same page (the error path, not the success path). That ruled out the WAF and 404s.
  2. The server's mail log — There was not a single line showing an attempt to send mail during that period. That ruled out SMTP auth and relay issues, because an attempted delivery that was rejected would have left a line.
  3. laravel.log — No exceptions, and none of the warnings I had set up around Mail::send, even though the controller already had a try/catch with logging.
  4. Conclusion from the three checks above — The app reached a certain point and returned before getting to the line that sends mail. All that remained was to find that point.
# 1) request ถึงแอปไหม และแอปตอบอะไร
grep 'POST /contact' ~/domains/app.example.com/logs/*access.log | tail -5
# 203.0.113.45 - - [11/Aug/2026:10:22:41] "POST /contact HTTP/2.0" 302 ...

# 2) มีการส่งเมลจริงไหมในช่วงเวลานั้น
sudo grep '10:22' /var/log/exim/mainlog | grep -i contact
# (ว่างเปล่า)

# 3) แอปบ่นอะไรไว้ไหม
tail -50 ~/domains/app.example.com/public_html/storage/logs/laravel.log
# (ไม่มีอะไรในช่วงเวลานั้น)

Once I reached step 4, I opened the controller and read it line by line. I found a bot check sitting before Mail::send — the code looked roughly like this.

// app/Http/Controllers/ContactController.php (ก่อนแก้)
$verify = Http::asForm()->post('https://challenges.example/siteverify', [
    'secret'   => config('services.captcha.secret'),
    'response' => $request->input('cf-turnstile-response'),
    'remoteip' => $request->ip(),          // <- ตัวปัญหาอยู่บรรทัดนี้
]);

if (! ($verify->json('success') ?? false)) {
    return back()->withErrors(['captcha' => 'ยืนยันไม่ผ่าน กรุณาลองใหม่']);
}

Mail::to(config('mail.contact_to'))->send(new ContactMessage($data));   // ไม่เคยมาถึง

The captcha service takes remoteip and compares it with the IP that answered the original challenge. It saw the actual visitor coming from 203.0.113.45, but my server reported 127.0.0.1, so it returned success:false, and the controller returned right there. That was it — no component had done anything wrong; each had done exactly what it was supposed to do, but the combined result was a broken form.

One short temporary route was enough to confirm the suspicion.

Route::get('/whoami', fn (Request $r) => [
    'ip'        => $r->ip(),
    'ips'       => $r->ips(),
    'remote'    => $r->server('REMOTE_ADDR'),
    'xff'       => $r->header('X-Forwarded-For'),
]);
{"ip":"127.0.0.1","ips":["127.0.0.1"],
 "remote":"127.0.0.1","xff":"203.0.113.45, 198.51.100.7"}

The headers had contained all the information all along. The app simply refused to read it.

Which fix to use — remove the parameter, or read the IP correctly?

Quick fix

Remove remoteip from the request

This parameter is optional; sending just the token still passes verification normally. Change one line and the form works again immediately. This suits apps that do not need the real IP anywhere else.

Structural fix

Configure TrustProxies to trust the local proxy

The app gets the real IP back across the system — throttle, log, ban, and audit trail all work correctly. But the trust settings must be narrow, and I need to understand what I am trusting.

That time, I chose to do both. I removed remoteip first to get the form working right away, then configured TrustProxies afterward, because this app also had an IP-based rate limit that was putting everyone in the world into a single bucket called 127.0.0.1 — meaning ten normal users using it at once could already block one another, and banning one person would effectively ban the whole site.

// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    // เชื่อเฉพาะ proxy ที่อยู่ในเครื่องเดียวกันเท่านั้น
    $middleware->trustProxies(
        at: ['127.0.0.1', '::1'],
        headers: Request::HEADER_X_FORWARDED_FOR
               | Request::HEADER_X_FORWARDED_HOST
               | Request::HEADER_X_FORWARDED_PORT
               | Request::HEADER_X_FORWARDED_PROTO,
    );
})

Why '*' must not be used, even though it also makes the check pass

Advice all over the internet says to set at: '*' and be done with it. That really does work — and it really does let people spoof their own IP addresses.

The mechanism is in nginx. Normally, header forwarding is configured like this.

# ฝั่ง nginx ที่คุมหน้า — ค่านี้คือค่ามาตรฐานที่ DA ใช้
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;

$proxy_add_x_forwarded_for means "take the value the client sent, then append the client's real IP." It does not overwrite it. If the client supplies a header of X-Forwarded-For: 8.8.8.8, the value reaching the app is 8.8.8.8, 203.0.113.45.

Symfony, which Laravel is built on, then walks the values from right to left, discarding entries in the trusted list until it reaches the first one it does not trust — that is the answer returned by ip(). If I tell it to trust everyone, it discards entries all the way through the chain and ends up with the leftmost value, which is the value the client supplied.

trusted = 127.0.0.1, ::18.8.8.8Client-supplied203.0.113.45nginx appends127.0.0.1REMOTE_ADDRSkipip() = 203.0.113.45 Stop at first untrustedtrusted = '*'8.8.8.8Client-supplied203.0.113.45nginx appends127.0.0.1REMOTE_ADDRSkipSkipip() = 8.8.8.8 Client-supplied value
Symfony walks the values from right to left and stops at the first untrusted entry. Trusting only 127.0.0.1 yields the real IP appended by nginx, but trusting everyone takes it all the way to the value supplied by the client.
The actual consequences of using '*'

IP-based rate limits can be bypassed by randomizing the header on every request · IP bans immediately become ineffective · Audit logs record addresses written by the attacker · Systems that allow certain IP ranges to access admin pages can be bypassed with a single header line. All of this happens silently, with no visible errors.

The rule I follow is that the trust list must contain only proxies I control. On a shared host like this, that means only 127.0.0.1 and ::1. To get the real IP from before the CDN as well, do not establish that trust on the PHP side — let nginx make the decision at the first layer with the real_ip module, then let PHP continue trusting 127.0.0.1 as before. This keeps the CDN's IP ranges in a config file that users cannot forge.

# ให้ nginx เขียน REMOTE_ADDR ใหม่จาก header ของ CDN เฉพาะเมื่อมาจากช่วง IP ของ CDN จริง
set_real_ip_from 203.0.113.0/24;
set_real_ip_from 198.51.100.0/22;
real_ip_header CF-Connecting-IP;
real_ip_recursive on;

Verify directly that the fix works

# ยิงพร้อม header ปลอม ถ้าตั้งถูก ต้องได้ IP จริงของเรา ไม่ใช่ 8.8.8.8
curl -s https://app.example.com/whoami -H 'X-Forwarded-For: 8.8.8.8'

The result is correct when ip is the real address of the machine sending the request, rather than 127.0.0.1 (meaning the proxy is still not trusted) or 8.8.8.8 (meaning the trust is too broad). Delete the test route afterward. Do not leave it there.

127.0.0.1IP the app sees
3 layersProxies traversed
0Log lines
2 addressesIPs to trust

The lesson I took from this case is that when "clicking does nothing, but every layer's logs are silent," I should stop looking for the word error and look for return statements before the actual work instead, because code working exactly as written has no reason to complain. For anyone configuring TrustProxies, choose the narrowest setting that works. Do not put in an asterisk just because an example on the internet uses one. If I have left anything out or made a typo, I will come back and correct it later.

Want us to set this up?

Get help with server setup, configuration, migration, or troubleshooting. Save time for the work that matters.

Get a quote

Hit something like this? Stuck somewhere?

Come trade notes on the LinuxThai board

→ Continue on the board

Comments 0

No comments yet — be the first