Blog

A first-party API behind Cloudflare Pages

Our website and dashboard are static sites on Cloudflare Pages. A twenty-line Pages Function keeps the dashboard's session cookie first-party without giving up real client IPs.

2 min read The MapSocket team

The MapSocket website and dashboard are both static builds on Cloudflare Pages: Astro for mapsocket.com, React and Vite for app.mapsocket.com. Every push to main builds and deploys them, and pull requests get preview URLs. There’s no server of ours involved in sending you the HTML.

The dashboard still needs to talk to the API, which runs on Fly.io at api.mapsocket.com. How it does that is the interesting part.

The dashboard signs you in with a session cookie. If the browser called api.mapsocket.com directly, that cookie would be set by a different origin than the page: browsers increasingly treat that as third-party, and you’d need CORS with credentials on every endpoint.

So the dashboard never calls the API’s hostname. It calls /api/… on its own origin, and a Pages Function forwards the request:

// functions/api/[[path]].ts (abridged)
const target = new URL(url.pathname + url.search, env.API_ORIGIN);
const headers = new Headers(request.headers);
headers.set("X-Forwarded-Host", url.host);
headers.delete("X-MapSocket-Client-IP");      // never trust a client's copy
headers.delete("X-MapSocket-Proxy-Secret");
const ip = request.headers.get("CF-Connecting-IP");
if (ip && env.EDGE_PROXY_SECRET) {
  headers.set("X-MapSocket-Client-IP", ip);
  headers.set("X-MapSocket-Proxy-Secret", env.EDGE_PROXY_SECRET);
}
return fetch(target, { method, headers, body, redirect: "manual" });

The cookie is set for app.mapsocket.com, the page’s own origin, and no CORS is needed.

Real IPs, without trusting headers

Sign-in has per-IP rate limits. Behind a proxy, the API would see every request coming from Cloudflare. Trusting an X-Forwarded-For header would be worse: anyone can send one.

Cloudflare tells the function who the client is in CF-Connecting-IP. The function passes that on together with a shared secret, EDGE_PROXY_SECRET, which is set both on the Pages project and on the API. The API believes the client-IP header only when the secret matches. Copies a client sends itself are stripped before forwarding, so a forged header can’t get through.

The waitlist switch

Until the API was public, the Pages project had no API_ORIGIN. The function answered every /api call with a readable 503 api_not_deployed, and the dashboard showed a waitlist instead of sign-in. Going live meant setting two secrets on the Pages project and redeploying, with no code change.

One thing we got wrong

Pages serves /pricing/index.html at /pricing/ and redirects /pricing there with a 308. Our own links omitted the trailing slash. Visitors never noticed, but Google Search Console listed one of our translated pricing pages as “Page with redirect” and left it out of the index. Every internal link now ends in a slash, matching the canonical tags and the sitemap. If you host a static site on Pages, it’s worth checking your links for the same thing.