Online scoreboard — locking down a public api endpoint.
Math4Kids is a small timed math quiz I built for fun and learning. And because this is the second time I’ve built a small game hosted on Cloudflare, I’m writing down how I handled the sensitive parts, as much for my own reference as anyone else’s..
Once you put such a public write endpoint on the internet, a few questions show up. Who can post a score? How do you stop bots from flooding the board? How honest should you be about what you can actually guarantee?
The text is divided into 3 main parts: what is implemented, what is skipped, and what I accepted.
What I was worried about
Bots spamming fake scores. Someone breaking the API with a weird payload. Leaking something embarrassing in an error message. Secret keys ending up in the client bundle. Even if this is just a playground, common sense still applies, and the lesson learned here will compound into better instincts for future projects.
What I wasn’t worried about: cryptographically proving a human sat down and played the game. Anything running in the browser can be faked. I know this. I accepted it.
The stack
Next.js (edge runtime) deployed to Cloudflare Pages via [@cloudflare/next-on-pages](http://twitter.com/cloudflare/next-on-pages). Scores live in Cloudflare D1. There’s an in-memory fallback when no D1 binding is present, so plain next dev still works.
All the security logic sits in two route handlers: GET/POST /api/scores and POST /api/session.
Layer 1: Route handler hygiene
The boring stuff first.
SQL is parameterized via prepare().bind(), so usernames never get concatenated into a query string. Request bodies over 8 KB get rejected with HTTP 413 before JSON.parse ever sees them. Usernames are 1–32 chars with no ASCII control characters. Scores must be finite integers in [0, 500], a fast human probably can’t score more in a 60-second round.
Three per-IP rate limiters run in isolate memory: 120 req/min on GET /api/scores, 15 on POST /api/scores, 30 on POST /api/session. The client always gets a generic 500 on errors; real details stay in logs.
CORS is an exact-origin allowlist (ALLOWED_ORIGINS is a comma-separated env var). First-party requests, where the Origin matches the handler’s own URL, are always allowed (fixes e.g. preview branches).
Every response sets Cache-Control: no-store and CDN-Cache-Control: no-store. Without those, Cloudflare will happily cache the empty-leaderboard response on first request and you’ll spend an hour wondering why scores aren’t showing up. If you forget these, there is always manual cache busting to the rescue.
Reads are public. Writes are where the rules live.
Layer 2: Turnstile
I’m using the invisible widget. The site key goes in NEXT_PUBLIC_TURNSTILE_SITE_KEY (Next.js public env, fine to expose), the secret goes into Pages secrets via wrangler secret put. Server-side verification uses CF-Connecting-IP as remoteip, falling back to X-Forwarded-For.
The interesting bit is what gets checked beyond the basic success flag. The handler also asserts that data.action === “score_submit”, which rejects any token minted by a widget that doesn’t carry that action (including widgets running on other sites). It checks data.hostname against the configured ALLOWED_ORIGINS hostnames too (not in local dev).
The widget uses execution: “execute” and action: “score_submit”, which means it doesn’t run on render. The client triggers it once, shortly after mount, to proactively mint a session JWT so the first score submit is instant. Low friction for real users, real friction for unattended scripts.
It does not prove a human played the game. It just makes “run a curl loop” stop being viable.
Layer 3: Session JWTs so Turnstile isn’t per-request
Verifying a Turnstile token on every score post would be slow and irritating. So there’s a short-lived HS256 session JWT, minted in the route handler with the Web Crypto API. No library required for a payload this small.
The flow:
POST /api/sessionvalidates a Turnstile token (whenTURNSTILE_SECRET_KEYis set) and returns{ sessionToken }. IfSESSION_SIGNING_SECRETisn’t configured, the endpoint returns 404 and is effectively disabled.POST /api/scoresaccepts a valid session JWT in place of a Turnstile token. With both secrets configured, the JWT is tried first; a fresh Turnstile token is accepted as fallback in case the session-mint request fails.- The client caches the token in a React ref. Not localStorage, because I don’t want it surviving a reload. It refreshes 45 seconds before
expand retries once on a 401.
Payload looks like { v: 1, typ: “math4_score_sess”, jti, iat, exp }, signed HS256 with SESSION_SIGNING_SECRET. Default TTL is 300 seconds, configurable via SESSION_TTL_SEC and clamped to 120–900. The jti is a random UUID, but it isn’t tracked server-side, so a token is reusable for its full TTL window. More on that in the gaps section.
Layer 4: The client-side submission gate
The SubmitScoreScreen component checks scores.length < 10 || score > scores[9]?.score before showing the name input. If your run wouldn’t crack the top 10, you see a “you didn’t make it” screen instead of a form.
This isn’t security. It’s protection against accidents. Someone with devtools open for five minutes can bypass it, and that’s fine. The point is to keep the experience clean for honest players, who are the overwhelming majority of anyone who’ll ever touch this thing.
Secret hygiene
Three categories, three handling rules:
NEXT_PUBLIC_TURNSTILE_SITE_KEY lives in wrangler.toml [vars]. It’s public by design; committing it is correct.
TURNSTILE_SECRET_KEY and SESSION_SIGNING_SECRET go through wrangler secret put in production, .dev.vars for wrangler pages dev, or .env.local for plain next dev.
There’s an .env.example documenting which is which and how to mint the signing secret (openssl rand -base64 48 does it).
The line between wrangler.toml [vars] (committed config, public) and wrangler secret (encrypted, never in git) is the thing to get right. Everything else is detail.
What I accepted
A few honest gaps:
Scores can be faked. There’s no server-side simulation of the game. The leaderboard reflects what clients report, not what they played.
Session tokens are reusable within their TTL. The jti is minted but not tracked, so a single valid token can submit several scores until it expires (5 minutes by default). For a hobby game that’s fine. For anything with stakes you’d want a server-side store.
Per-isolate rate limits aren’t global. Workers run in multiple isolates across edge nodes, so a distributed flood will get past the limiter. If you need genuinely global rate limiting, that’s Durable Objects or Cloudflare’s rate limiting product, not a Map in isolate memory.
Client-side controls are client-side controls. The submission gate is honest UX, not a barrier.
The read API is public and is staying that way. Someone scraping the top 10 isn’t a problem I plan to solve.
What I’d add if the stakes went up
A server-side jti blocklist would close the reusable-token gap. Use a KV namespace with a TTL matching SESSION_TTL_SEC. POST /api/session writes the ID; POST /api/scores rejects any jti already in the store. One score per session token. KV’s eventual consistency is good enough for this; the occasional duplicate inside a narrow race window doesn’t matter for a leaderboard.
A Durable Object rate limiter would replace the per-isolate Maps. Single object keyed on IP, single atomic counter, genuinely global. Worth the complexity if distributed abuse becomes a real pattern.
A signed score payload would close the obvious cheating vector partway. At the end of a game, the server issues a short-lived token that commits to { score, timestamp }. The client sends that on submit; the server verifies it’s the same token it issued. This doesn’t stop someone who reverse-engineers the game flow, but it does stop arbitrary number injection without a prior server round-trip.
An admin panel would be handy: even an admin-only delete endpoint, something as simple as DELETE /api/scores/:id behind a bearer token, removes the need to open a D1 console to clean up.
Structured logging into Cloudflare Logpush, or a tail worker dumping into R2, gives you something to actually query when the board starts looking weird. A burst of submissions from one IP, all just above the #10 threshold, is exactly the kind of pattern you want to be able to see.
TL;DR
Parameterize your SQL. Cap your payloads. Validate your inputs. Bind Turnstile tokens to the action and hostname you expect. Keep secrets out of the bundle. Be honest about what you can guarantee.
It’s a toy, so I didn’t overbuild it.. I also didn’t leave the door open.
If you find something broken in my deployment, please reach out privately.

Top comments (0)