tailscale serve terminates HTTPS at the Tailscale layer and forwards plain HTTP to a local backend. From the application’s perspective, every request originates at 127.0.0.1 — regardless of which Tailscale node made the request. This collapses the identity of all callers to the loopback address.
Why it matters
The typical pattern for Tailscale-gated PHP apps is to check REMOTE_ADDR against the Tailscale CGNAT range (100.64.0.0/10) or to trust the Tailscale-User-Login header injected by tailscale serve. These two signals are mutually exclusive:
- If you check
REMOTE_ADDRfor Tailscale IPs → you get the real node IP, buttailscale serveis not in the picture (you’re listening directly on the Tailscale interface or binding to the node’s IP). - If you rely on
Tailscale-User-Login→ the proxy is in the picture, andREMOTE_ADDRis127.0.0.1.
A guard like this silently bypasses all auth when tailscale serve is the deployment method:
$isLocal = in_array($_SERVER['REMOTE_ADDR'], ['127.0.0.1', '::1'], true);
if ($isLocal) return; // exits before checking Tailscale-User-Login
The Tailscale-User-Login branch is never reached. Every request is treated as a trusted local caller.
The fix
Drop the REMOTE_ADDR shortcut entirely. When tailscale serve is the proxy, the only reliable identity signal is the injected header:
function requireTailscaleAuth(): void
{
$login = $_SERVER['HTTP_TAILSCALE_USER_LOGIN'] ?? '';
if ($login !== 'allowed@example.com') {
http_response_code(403);
header('Content-Type: text/plain');
echo 'Forbidden';
exit;
}
}
The isLocal escape hatch is often added for local development. It’s unnecessary: dev environments using php -S route through a custom router that doesn’t call the auth function, so removing the bypass doesn’t break anything.
The underlying mechanic
tailscale serve is a reverse proxy, not a network-layer filter. It listens on the Tailscale interface, handles TLS, injects identity headers, and makes a new outbound connection to localhost:PORT. The application sees a fresh TCP connection from the loopback. There is no X-Forwarded-For or equivalent by default — the node IP is only available in the injected Tailscale-User-* headers.
This is architecturally correct behavior for a proxy, but it breaks assumptions borrowed from traditional firewall-based access control where IP address implies identity.