> ## Documentation Index
> Fetch the complete documentation index at: https://docs.zespan.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Client IP behind a proxy

> Zespan trusts the proxy chain in X-Forwarded-For. Your edge must overwrite that header, not append to it, or a caller can choose the IP a human is shown when approving a CLI sign-in.

The Zespan API runs behind a load balancer, reverse proxy, or CDN in every
deployment shape, so it derives the caller's address from the proxy chain rather
than from the TCP socket. That derivation is only as honest as the edge in front
of it.

<Warning>
  **Your edge must overwrite `X-Forwarded-For`, not append to it — and the API
  must not be reachable except through that edge.** If a caller's own
  `X-Forwarded-For` survives to the API, that caller chooses the address Zespan
  records and displays. [What breaks](#what-a-forgeable-ip-costs-you) is one
  specific control: the device line on the CLI approval screen.
</Warning>

## How Zespan resolves the address

The API is configured to trust the whole proxy chain. For each request it takes
the **leftmost** entry of `X-Forwarded-For`, falling back to the socket address
when the header is absent. Three things read the result:

| What                                 | Uses the address for                                                                                                                                                               |
| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| The CLI approval screen at `/device` | The **Request came from** row of "The terminal that asked" — the line a human compares against the machine in front of them. See [zespan auth](/cli/auth#the-terminal-that-asked). |
| The device-code rate limit           | Bucketing anonymous sign-in starts, 30 per 5 minutes per address.                                                                                                                  |
| The [audit log](/platform/audit)     | The `ipAddress` on every recorded action, stored truncated to a /24.                                                                                                               |

<Note>
  Zespan never reads `X-Forwarded-For` directly in application code, and the CLI
  cannot name its own address in the request body — the API stamps the resolved
  address itself. Neither of those helps if the header arrives already poisoned:
  a chain the edge did not sanitise is indistinguishable from a real one.
</Note>

## What to configure

The rule is the same everywhere: **replace** the inbound header with what the
edge itself observed, and refuse traffic that bypasses the edge.

<Steps>
  <Step title="Make the edge overwrite X-Forwarded-For">
    Set the header to the connecting address the edge saw. Do not use a directive
    that appends to a client-supplied value.

    | Edge                       | Directive                                                                                                                                                                               |
    | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
    | nginx                      | `proxy_set_header X-Forwarded-For $remote_addr;` — **not** `$proxy_add_x_forwarded_for`, which appends to whatever the client sent                                                      |
    | Caddy                      | `reverse_proxy` sends `X-Forwarded-For` from the connecting address by default; leave `trusted_proxies` unset unless there is a further hop in front                                    |
    | HAProxy                    | `option forwardfor` appends by default — add `http-request set-header X-Forwarded-For %[src]` before it, or use `except` carefully                                                      |
    | Traefik                    | Set `forwardedHeaders.insecure: false` and list only your own upstream under `forwardedHeaders.trustedIPs`                                                                              |
    | Envoy / Istio              | Set `use_remote_address: true` with `xff_num_trusted_hops` matching the real number of hops                                                                                             |
    | ingress-nginx (Kubernetes) | `use-forwarded-headers: "false"` in the controller ConfigMap makes it rewrite rather than trust an inbound chain                                                                        |
    | AWS ALB / GCP LB           | These append. Put a rule in front that strips the client's `X-Forwarded-For` before the balancer sees it, or drop the header at the WAF                                                 |
    | Cloudflare                 | The true address is in `CF-Connecting-IP`. Cloudflare does **not** discard a client-supplied `X-Forwarded-For` — add a Transform Rule that sets `X-Forwarded-For` to `cf.connecting_ip` |
  </Step>

  <Step title="Close the path around the edge">
    Bind the API to a private interface, or restrict it with a firewall or
    security group so the only route in is the proxy. An attacker who can open a
    TCP connection directly to the API supplies the entire chain, and no header
    configuration at the edge can help.
  </Step>

  <Step title="Verify it with a request, not by reading config">
    Send a request through your public edge carrying a bogus chain, and look at
    what Zespan recorded:

    ```bash theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
    curl -sS -X POST https://api.yourdomain.com/v1/auth/device/code \
      -H "content-type: application/json" \
      -H "x-forwarded-for: 203.0.113.9" \
      -d '{"client_id":"zespan-cli"}'
    ```

    Open your dashboard's `/device` screen, enter the `user_code` from the
    response, and press **Continue**. Read the **Request came from** row: it must
    show **your** address, not `203.0.113.9`. Press **Cancel this request** when
    you are done — nothing is granted either way.

    <Tip>
      The audit log answers the same question after the fact: sign in through the
      edge with a bogus `X-Forwarded-For` and check the IP on the resulting entry
      under **Platform → Audit**. That value is truncated to a /24, so compare the
      first three octets.
    </Tip>
  </Step>
</Steps>

## What a forgeable IP costs you

Be precise about the exposure, because it is narrower than it first looks and
sharper than "an IP might be wrong".

**A device grant is phishable by construction.** Someone can run
`zespan auth login` on their own machine and read you the eight-character code
over the phone. Everything else on the approval screen — the code you typed, the
fact that you are signed in, your session — is exactly the same in that attack as
in a legitimate login. The device line is the only part of the screen that
differs, and the IP is the row an attacker cannot influence any other way: they
control the CLI, so they control the reported OS and CLI version, but not the
address the API observed.

**So a forgeable IP silently weakens the one screen defending the flow.** It does
not throw an error or look wrong — it shows a plausible address, which is worse
than showing nothing, because the row exists to be trusted. An attacker who can
reach the API directly can make the screen read as the victim's own office.

**Nothing else in the flow depends on it.** State this plainly to yourself before
deciding how urgent this is:

* **Ownership** — a grant can only be approved by the signed-in account that
  claimed it, checked server-side.
* **The claim step** — the code must be entered by a human in a browser; the
  address is never part of that check.
* **The second factor** — an account with an enrolled second factor must re-prove
  it within five minutes of approving, independently of any header.
* **Revocation** — a revoked device is refused at approval *and* again at token
  exchange, keyed on its device key.
* **Attribution** — the audit trail names the user, session, and device from
  server-side records, not from a header.

A poisoned IP also lets a caller sidestep the device-code rate limit by rotating
the header, since the bucket is keyed on the same resolved address. That costs
storage rather than access: the rows created are short-lived and
[swept on a timer](/cli/auth#abandoned-logins-are-cleaned-up).

## Next steps

<CardGroup cols={2}>
  <Card title="Environment variables" icon="sliders" href="/platform/environment-variables">
    `NEXT_PUBLIC_APP_URL` and the rest of the server configuration.
  </Card>

  <Card title="zespan auth" icon="log-in" href="/cli/auth">
    The CLI sign-in this screen approves, and what the device line shows.
  </Card>

  <Card title="Security" icon="shield" href="/platform/security">
    Encryption, authentication, tenant isolation, and data controls.
  </Card>

  <Card title="Audit log" icon="scroll" href="/platform/audit">
    What Zespan records about every action, including the caller's address.
  </Card>
</CardGroup>
