This page is for whoever is implementing the token server — usually the same team that owns the upstream API integration. If you’re just sending requests through Proxy Hopper and someone else runs the token server for your target, see Authenticating instead — that page covers a different thing, authenticating your requests to Proxy Hopper, not the token server that Proxy Hopper itself calls.
What you’re building
A token server is a small HTTP service with two endpoints:POST /token— given a target name, a proxy IP, and an opaque cursor, return the headers to inject into the upstream request, when they expire, and an updated cursor.GET /health— return2xxonce ready. Proxy Hopper calls this at startup before pre-warming tokens for every configured(target, IP)pair.
proxy-hopper-token-server library gets you a tested implementation of both endpoints for free: you write one async method, TokenProvider.get_token(), and it handles the FastAPI app, request validation, timeout enforcement, and error responses.
Scope credentials per IP
Read this before writingget_token() — it’s the one rule that matters more than everything else on this page.
Proxy Hopper calls your token server separately for every (target, proxy-IP) pair — the request body always includes req.ip. This exists so that each proxy IP can carry its own distinct credential, not so every IP conveniently shares one.
The reason: Proxy Hopper’s entire purpose is to spread requests across a rotating pool of IPs so a target can’t easily tell they all come from the same caller. If get_token() ignores req.ip and returns the same static token for every IP — a single OAuth client-credentials token fetched once and handed back for every request, for example — Proxy Hopper will still cache it separately per IP, but the credential value itself becomes the one constant thread linking every IP in the pool back to the same caller. That silently defeats the whole point of the pool, and it’s a much easier signal for a target to key on than IP address alone.
This doesn’t apply to upstream auth that’s genuinely IP-agnostic by nature — a single organisation-wide API key with no per-session concept, for instance. There, one shared credential is correct because there’s nothing to scope: the upstream can’t distinguish sessions either way. The mistake is only returning a shared credential for an auth mechanism that does support scoping, and not taking advantage of it.
Quick start (using the library)
TokenProvider:
config.yaml:
POST /token before forwarding through this target, caches the response per (target, ip), and refreshes it before expires_at.
The wire contract
If you’re implementing this from scratch (a different language, or embedding it in an existing service), your server needs to expose exactly this:POST /token — request body:
proxy_url is only present when server.authServer.exposeProxyUrl: true is set (the default) — see IP-pinned acquisition below for why you’d want it.
Response body:
GET /health — must return 2xx once ready.
Using the library, TokenServer/create_app build exactly this contract for you — you only ever touch TokenRequest/TokenResponse dataclasses, never raw HTTP.
The cursor mechanism
cursor is an opaque JSON object Proxy Hopper stores per (target, ip) pair and echoes back on every subsequent call — a small key-value slot for anything you need to correlate calls (a refresh token, a session ID, a call counter) without running your own database. Return the incoming cursor unchanged if you don’t need it.
IP-pinned acquisition
Some auth endpoints tie a session or token to the IP address that requested it. If yourget_token() needs to make its own outbound call (e.g. logging in) from the same proxy IP that will later use the resulting token, route that call back through Proxy Hopper on a pinned IP using ProxyHopperClient:
ProxyHopperClient sends the request to Proxy Hopper’s own address using the same header-based forwarding every other client uses (X-Proxy-Hopper-Target + X-ProxyHopper-Force-IP) — it does not use a classic HTTP-proxy or CONNECT-tunnel request, since Proxy Hopper’s core doesn’t implement those modes. req.proxy_url is only populated when exposeProxyUrl: true; without it, make the call with a plain HTTP client instead (no IP pinning).
client.post() / .get() return a ProxyHopperResponse (.status, .headers, await .read(), .text()) with the body fully buffered — safe to read after the call returns, and safe to read more than once.
See
auckland_council.py in the repo for a complete, real-world TokenProvider that uses IP-pinned acquisition and carries a session cookie in the cursor across refreshes.Common patterns
OAuth2 client credentials
Client-credentials tokens authenticate your application, not a session — there’s no login and no per-IP concept in this grant type, so every IP ends up presenting the same client identity to the upstream regardless of what
get_token() does with req.ip. That’s the “genuinely IP-agnostic” case from Scope credentials per IP — nothing to fix here. If the target instead supports session or refresh-token based auth, prefer one of the two patterns below, which do give each IP a distinct credential.Refresh-token exchange (state carried in the cursor)
This pattern gives each IP its own credential without ever branching onreq.ip directly — it relies on req.cursor already being scoped per (target, ip) by Proxy Hopper. The first call for a given IP has an empty cursor and mints a fresh token pair for that IP alone; every later call for that same IP refreshes from its own stored refresh token. No two IPs ever share a refresh token or the access token derived from it.
Session-cookie auth
If the upstream needs a session cookie instead of a bearer token, return it as aCookie header instead of Authorization. Like the refresh-token pattern, this is correctly scoped per IP for free — Proxy Hopper calls get_token() independently for each IP, so each IP performs its own /login and gets back its own session cookie, never a shared one:
Multiple providers, one server
Route by target name by passing adict[str, TokenProvider] instead of a single provider:
404.
Testing your provider
TokenServer.build_app() returns a plain FastAPI app — test it with fastapi.testclient.TestClient, no network and no running Proxy Hopper instance needed:
Failure behavior
Any exception raised fromget_token() becomes a 500 with {"error": "provider_error", "detail": str(exc)}. Exceeding the configured --timeout becomes a 504 with {"error": "timeout", ...}. Proxy Hopper treats repeated failures as AUTH_BROKEN for that IP — see Token Server (Admin) — Broken-state and recovery.
CLI reference
Hosting behind your own ASGI server
For local development with autoreload, or to embed the token endpoints in a larger FastAPI app, useTokenServer.build_app() to get the plain ASGI app instead of calling .run():
Full runnable example
The repo includes an end-to-end example with a from-scratch FastAPI token server, Docker Compose wiring, and recipes for swapping in real OAuth2/session-cookie/rotating-key auth:examples/token-server/
Complete Docker Compose stack — token server + Proxy Hopper — with a signed-JWT demo you swap for your real auth mechanism.
auckland_council.py
A real-world provider using IP-pinned acquisition and a cursor-carried session cookie, built on this library.
Next steps
Managed Auth
The concept guide — caching, refresh timing, and broken-state recovery.
Token Server (Admin)
Deployment, configuration fields, and operational metrics/logs.