GenerateSaaS

Client IP

How the backend works out which proxy is in front of it on its own, and TRUSTED_PROXY - the override for the one topology it cannot see.

The backend rate-limits by client IP and stamps it on every audit entry. Behind a proxy that address arrives in a request header, and a header is something any caller can type - so it is trusted only when something in front is known to have set it. You almost certainly do not need to configure this: the backend detects what is in front of it at startup and per request.

What the backend detects on its own

What it seesWhat it trusts
A platform env var - VERCEL, CF_PAGES, FLY_APP_NAME, RENDER, RAILWAY_ENVIRONMENT_NAME, NETLIFY, COOLIFY_*That platform's edge: Vercel's and Cloudflare's single-value headers, one forwarded hop elsewhere
A private peer - Docker, Compose, Kubernetes, Dokploy, or an Nginx, Traefik or Caddy on the same hostOne forwarded hop. A client on the public internet cannot have a private source address, so a private peer proves something forwarded the request
A public peer, no platform markerNothing. Whoever opened the connection is the caller

Precedence: TRUSTED_PROXY if set → the platform → the connection peer → trust nothing.

The one case detection cannot see: a CDN in front of a directly exposed server. Cloudflare connects from a public address, so that deployment looks identical to one with nothing in front - set TRUSTED_PROXY=cf-connecting-ip. The backend logs one warning the first time it ignores a forwarded header, naming the header and the value to set.

Setting TRUSTED_PROXY

ValueSet it when
unsetDefault - anything not listed below. Detects the topology above
cf-connecting-ip, fly-client-ip, …A CDN fronts a directly exposed server and overwrites this single-value header on every request, so its value is the client
1, 2, 3That many undetected proxies each append to x-forwarded-for; the client is that many entries from the right. 2 for Cloudflare plus your own proxy
falseNothing is in front - trust no header at all

Hop counts read from the right because each proxy appends the address it received from, so anything a caller prepends is never reached.

Never set a hop count you do not have. TRUSTED_PROXY=1 reads the client from x-forwarded-for instead of the connection, so on a directly exposed server anyone can claim any address. A wrong value is worse than none - it overrides the detection that would have been right.

Which mount you have

Your projectWhere the API runsIdentifies a caller when nothing is detected
Fullstack - apps/web/app/api/[[...rest]]/route.ts existsThe frontend's own route handler, on a web RequestNothing. That request carries no connection address, so every caller resolves alike
Separate - that file is absent, apps/backend is deployedIts own long-running serverThe connection peer, read off the socket

The fullstack mount has no connection to fall back on, so on an unrecognised host every caller shares one bucket - logged at error level rather than failing quietly.

apps/web/instrumentation.ts refuses to start a production server on a TRUSTED_PROXY it cannot parse - true, 0, anything that is neither a hop count nor a header name - because the alternative is a clean boot and a 500 on every API request. An unset value is never refused; next build is unaffected.

Verify it

Ask the backend what address it sees and compare it with your own:

curl -s https://your-app.example.com/api/public/v1/ip   # what the backend resolved
curl -s https://api.ipify.org                           # your actual address
ResultMeaning
They matchCorrect
A private address (10.x, 172.16-31.x, 192.168.x)Hop count too low, or the wrong header for your platform
?No address resolved at all

Then confirm a caller cannot move it - the property that actually matters:

curl -s -H "X-Forwarded-For: 8.8.8.8" https://your-app.example.com/api/public/v1/ip

The answer must be unchanged. 8.8.8.8 means your hop count is too high.

Too low is safe, too high is not

  • Too low - you read a proxy's address, or the shared unknown bucket. Not a way past the limiter, but a lockout lever: one caller can spend the whole sign-in budget and every other user gets 429 until the window rolls. At the shipped 10/min that is your entire login flow, held down by one script.
  • Too high - you read an entry still under the caller's control. They pick their own bucket and choose what the audit trail records. Count only the proxies that actually append.

Unknown callers share a bucket rather than skipping the limiter, so the failure is always closed. Naming the proxy is the real fix - it gives every caller its own bucket. Where a deployment genuinely cannot resolve addresses, AUTH_RATE_LIMIT_MULTIPLIER widens the shared budget deliberately, trading brute-force protection for lockout resistance.

What reads the client IP

SurfaceUses it for
Global and per-route rate limitsThe bucket key
Sign-in, sign-up, magic-link, 2FA limitsBrute-force bounding on auth endpoints
Audit logThe ipAddress on every entry - left empty when nothing is trusted, so it reads as unknown rather than as evidence
Country auto-detectGeolocation fallback when no CDN country header is present

On this page