Relayer
DocsOperationsorigin-guard

Origin Guard & Trusted Proxy#

For apps behind Cloudflare or a reverse proxy, Relayer ships OriginGuard, which rejects requests that bypass the CDN, and TrustedProxy, which recovers the real client IP.

Enable with environment variables#

Relayer::boot() reads these variables and sets the result as the router's guard, which runs before /_defer/{name}, the dev profiler and middleware.php. With none set, nothing is added.

ORIGIN_SECRET=<long random value>     # enables OriginGuard
ORIGIN_SECRET_PREVIOUS=<old value>    # also accepted during rotation
ORIGIN_GUARD_HEADER=X-Origin-Secret   # default
ORIGIN_GUARD_EXCEPT=/healthz,/_health/*   # exact match, or `/prefix/*`
ORIGIN_GUARD_MODE=enforce             # or report

TRUSTED_PROXIES=fdaa::/16             # enables TrustedProxy (comma-separated CIDRs)
TRUSTED_PROXY_PRESET=fly              # fly | cloudflare | xff (default)
CLOUDFLARE_IP_RANGES=173.245.48.0/20,…  # adds a Cloudflare layer behind the first one

Do not put origin protection in middleware.php. It wraps route dispatch only, so hitting /_defer/{name} directly would bypass it. To compose your own, AppRouter::setGuard() takes the same fn(Request, $next) and replaces the one built from the environment.

<?php
// public/index.php
use Polidog\Relayer\Http\OriginGuard;
use Polidog\Relayer\Relayer;
use Psr\Log\LoggerInterface;

Relayer::boot(__DIR__ . '/..')
    ->setGuard(OriginGuard::middleware([
        'header'         => 'X-Origin-Secret',                 // default
        'secretsFromEnv' => ['ORIGIN_SECRET', 'ORIGIN_SECRET_PREVIOUS'],
        'except'         => ['/healthz'],                      // exact match, or `/prefix/*`
        'mode'           => 'enforce',                         // or 'report'
        'logger'         => Relayer::container()->get(LoggerInterface::class), // optional
    ]))
    ->run();

OriginGuard#

Verifies the secret header the CDN adds when forwarding, and answers 403 when it is missing or wrong.

  • Off by default. With no non-empty secret it passes everything, so local runs and CI do not fill with 403s.
  • secretsFromEnv lists environment variable names. Values are trimmed, so a trailing newline cannot make every request 403. You can also pass values directly as secrets.
  • Old and new secrets both pass during rotation, compared with hash_equals.
  • except excludes paths such as platform health checks.
  • mode: 'report' logs instead of rejecting, for a trial run before enforce.
  • Logs hold only the method, path and client IP, never the secret or what the client sent.
  • Pass secrets through fly secrets set or a gitignored .env.local.

TrustedProxy#

Resolves the client IP from a forwarding header, but only when the last hop (REMOTE_ADDR) is a trusted proxy. Read the result with $request->clientIp(). $request->ip() stays REMOTE_ADDR, and clientIp() falls back to ip() when nothing resolves.

use Polidog\Relayer\Http\TrustedProxy;

// Cloudflare in front: CF-Connecting-IP. Ranges are not bundled;
// fetch https://www.cloudflare.com/ips-v4 and /ips-v6 and pass them
TrustedProxy::middleware(['preset' => 'cloudflare', 'proxies' => $cloudflareRanges]);

// Fly.io: Fly-Client-IP, trusted only from Fly's private network
TrustedProxy::middleware(['preset' => 'fly', 'proxies' => ['fdaa::/16']]);

// Your own proxy: walks X-Forwarded-For from the right, skipping your proxies
TrustedProxy::middleware(['preset' => 'xff', 'proxies' => ['10.0.0.0/8']]);

// Any single-IP header (cannot be combined with `preset`)
TrustedProxy::middleware(['header' => 'X-Real-IP', 'proxies' => ['192.0.2.1']]);
  • proxies is required: single addresses or CIDRs (IPv4 / IPv6). An invalid range never matches.
  • A header from an untrusted hop or an invalid IP value is ignored.
  • Hop verification can only be skipped with an explicit 'trustAnyHop' => true, and only when that proxy is provably the sole path to the process.

Multiple proxies (chain)#

With Cloudflare → Fly, Fly-Client-IP is Cloudflare's connecting IP and the user is in CF-Connecting-IP. List layers outermost last; each applies only when the address resolved so far is one of that layer's proxies.

TrustedProxy::middleware(['chain' => [
    ['preset' => 'fly',        'proxies' => ['fdaa::/16']],
    ['preset' => 'cloudflare', 'proxies' => $cloudflareRanges],
]]);

Recipe: Cloudflare + Fly.io#

  1. In Cloudflare → Rules → Transform Rules → Modify Request Header, set X-Origin-Secret to a long random value for the zone.
  2. Give the app the secret and the trust chain, with no PHP.
   fly secrets set \
     ORIGIN_SECRET=<that value> \
     ORIGIN_GUARD_EXCEPT=/healthz \
     TRUSTED_PROXIES=fdaa::/16 \
     TRUSTED_PROXY_PRESET=fly \
     CLOUDFLARE_IP_RANGES="$(curl -s https://www.cloudflare.com/ips-v4 https://www.cloudflare.com/ips-v6 | paste -sd,)"
  1. Check: curl -I https://<app>.fly.dev/ is 403, your custom domain is 200, and /healthz is 200 from both.

To rotate, set the new ORIGIN_SECRET plus ORIGIN_SECRET_PREVIOUS=<old value>, and remove the latter later. For CDN caching, see CDN Cache.

Last revised — 2026-11-01
Revisions (1)
  • v0.31.0: 新規ページ