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.
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.
Keep the cookie first-party
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.