Building a Secure API with Cloudflare Workers, Zod, and Rate Limiting
Validating every input at the edge, rate limiting without a database, and using signed state so a client can hold data it cannot forge.
Edge runtimes change the shape of backend security. There is no long-lived process to keep state in, no local filesystem, and execution is distributed across hundreds of locations. Patterns that work on a single Node server, an in-memory rate limit counter being the obvious one, quietly stop working.
This guide covers the approach I use for small, security-sensitive APIs on Cloudflare: validate everything with Zod, rate limit with durable or storage-backed counters, and push state to the client in a form the client cannot tamper with.
The techniques here are the same ones behind DevGuess, where the backend runs on Cloudflare Pages Functions and needs to keep a guessing game fair without storing a session server-side for every player.
Cloudflare Workers and Pages Functions share the same runtime and request model. Pages Functions are Workers with file-based routing attached to a Pages project. Everything below applies to both; use Pages Functions when the API lives alongside a static site, and standalone Workers when it does not.
The Edge Runtime Model
A Worker is a function that receives a Request and returns a Response. It runs in a V8 isolate rather than a container, which is what makes cold starts negligible, but it also imposes constraints worth internalising before designing anything:
- No Node APIs by default. No
fs, and Node built-ins need explicit compatibility flags. - No shared memory between requests. Two requests may run in different isolates in different cities. A module-level counter is not a reliable counter.
- Execution limits. CPU time per request is bounded, so heavy synchronous work is the wrong fit.
The second point is the one that causes real security bugs, because a naive in-memory rate limiter appears to work in testing and provides almost no protection in production.
Validate Every Input
The first rule is that no request body, query parameter, or header reaches business logic without validation. Zod makes this cheap enough that there is no excuse to skip it.
import { z } from "zod";
const GuessSchema = z.object({
roundId: z.string().uuid(),
guess: z.string().trim().min(1).max(64),
// Reject unknown keys instead of silently ignoring them.
}).strict();
type GuessInput = z.infer<typeof GuessSchema>;Three details in that schema do real work:
.strict()rejects unexpected properties. Without it, an attacker can append fields hoping something downstream reads them. This matters most when the parsed object is later spread into a database write..max(64)bounds the input. Unbounded strings are a denial-of-service vector when anything downstream does per-character work.z.infergives a type derived from the runtime check, so the compiler and the validator cannot disagree.
Wrap parsing so failures never leak internals:
export async function parseJson<T extends z.ZodTypeAny>(
request: Request,
schema: T
): Promise<z.infer<T> | null> {
let raw: unknown;
try {
raw = await request.json();
} catch {
return null; // Malformed JSON is indistinguishable from invalid input.
}
const result = schema.safeParse(raw);
return result.success ? result.data : null;
}Never return Zod's raw error object to an untrusted client. It describes your internal schema field by field, which hands an attacker a map of your data model. Log the detail server-side and return a generic 400 to the caller.
Validate the response side too, when calling third-party APIs. An upstream service changing its payload shape should fail loudly in one place rather than producing undefined deep in your logic.
Rate Limiting Without a Traditional Database
Rate limiting needs shared, mutable state, which is precisely what the edge model makes awkward. There are three practical options, and the right one depends on how strict you need to be.
Option 1: Cloudflare's built-in rate limiting
The simplest correct answer for coarse protection. It is configured rather than coded, has no storage cost, and handles the common case of "stop this IP from hammering the endpoint."
Its limitation is granularity: it is less suited to per-user quotas or business rules like "three attempts per round."
Option 2: KV — cheap, eventually consistent
KV is globally replicated with eventual consistency, so writes take time to propagate. A determined attacker hitting multiple regions simultaneously can exceed a KV-based limit.
That makes KV acceptable for abuse dampening, not for enforcing a hard security boundary.
const WINDOW_SECONDS = 60;
const MAX_REQUESTS = 30;
export async function checkRateLimit(
kv: KVNamespace,
identifier: string
): Promise<boolean> {
const key = `rl:${identifier}:${Math.floor(Date.now() / 1000 / WINDOW_SECONDS)}`;
const current = parseInt((await kv.get(key)) ?? "0", 10);
if (current >= MAX_REQUESTS) return false;
// expirationTtl lets the key clean itself up; no sweeper job needed.
await kv.put(key, String(current + 1), { expirationTtl: WINDOW_SECONDS * 2 });
return true;
}Two honest caveats. The read-then-write is not atomic, so concurrent requests can undercount. And the fixed window allows a burst of up to double the limit across a window boundary. Both are acceptable when the goal is to stop casual abuse; neither is acceptable when the limit protects something expensive.
Option 3: Durable Objects — strongly consistent
When a limit genuinely must hold, a Durable Object gives you a single-threaded, strongly consistent instance per key. Every request for a given identifier routes to the same object, so counters are exact.
export class RateLimiter {
private state: DurableObjectState;
constructor(state: DurableObjectState) {
this.state = state;
}
async fetch(): Promise<Response> {
const now = Date.now();
const windowStart = now - 60_000;
const timestamps = (await this.state.storage.get<number[]>("hits")) ?? [];
// Sliding window: drop anything older than the window.
const recent = timestamps.filter((t) => t > windowStart);
if (recent.length >= 30) {
return new Response("rate_limited", { status: 429 });
}
recent.push(now);
await this.state.storage.put("hits", recent);
return new Response("ok");
}
}The tradeoff is latency and cost. Requests route to wherever that object lives, so a user far from it pays a round trip. Use Durable Objects for limits that protect money or integrity, and KV or built-in limiting for everything else.
Choose your rate limit key deliberately. IP addresses are shared behind carrier NAT and corporate proxies, so aggressive IP limits can lock out many legitimate users at once. Where you have authenticated identity, key on the user; fall back to IP only for unauthenticated endpoints.
Always return 429 with a Retry-After header. Well-behaved clients respect it, and it makes your own debugging far easier.
Signed State: Letting the Client Hold Data It Cannot Forge
This is the pattern that makes stateless edge APIs practical.
Storing a server-side session for every anonymous player is expensive and mostly unnecessary. The alternative is to give the client its state along with a cryptographic signature. The client can read it, but any modification invalidates the signature.
// HMAC-SHA256 over a compact JSON payload, using Web Crypto.
async function importKey(secret: string): Promise<CryptoKey> {
return crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign", "verify"]
);
}
const toBase64Url = (bytes: ArrayBuffer): string =>
btoa(String.fromCharCode(...new Uint8Array(bytes)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
export async function signState(state: object, secret: string): Promise<string> {
const key = await importKey(secret);
const payload = toBase64Url(new TextEncoder().encode(JSON.stringify(state)).buffer);
const signature = await crypto.subtle.sign(
"HMAC",
key,
new TextEncoder().encode(payload)
);
return `${payload}.${toBase64Url(signature)}`;
}Verification must use the crypto API's own comparison rather than comparing strings:
const MAX_TOKEN_LENGTH = 8_192;
const fromBase64Url = (value: string): Uint8Array | null => {
if (!/^[A-Za-z0-9_-]+$/.test(value)) return null;
const padded = value.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (value.length % 4)) % 4);
try {
return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
} catch {
return null;
}
};
export async function verifyState<T>(
token: string,
secret: string
): Promise<T | null> {
const [payload, signature, extra] = token.split(".");
if (!payload || !signature || extra || token.length > MAX_TOKEN_LENGTH) return null;
const key = await importKey(secret);
const signatureBytes = fromBase64Url(signature);
if (!signatureBytes) return null;
// crypto.subtle.verify is constant-time; a manual === comparison is not
// and leaks information through timing.
const valid = await crypto.subtle.verify(
"HMAC",
key,
signatureBytes,
new TextEncoder().encode(payload)
);
if (!valid) return null;
const payloadBytes = fromBase64Url(payload);
if (!payloadBytes) return null;
try {
return JSON.parse(new TextDecoder().decode(payloadBytes)) as T;
} catch {
return null;
}
}Critical properties to understand before relying on this:
- Signed does not mean encrypted. The payload is base64, which is encoding, not secrecy. Anyone can read it. Never put anything confidential in signed state.
- Always include an expiry in the payload and check it. Without one, a token is valid forever, and a leaked token never stops working.
- Include a version field. When your logic changes, you need a way to reject tokens issued under old rules.
interface RoundState {
roundId: string;
attempts: number;
version: number;
exp: number; // Unix seconds
}
function isExpired(state: RoundState): boolean {
return state.exp < Math.floor(Date.now() / 1000);
}In DevGuess this is what keeps progress honest: the client carries its round state, but cannot increase its remaining attempts or claim a result the server did not issue, because doing so breaks the signature.
Signed state stops forgery, not replay. A client can reuse an earlier valid token to reset itself to a previous state. Where that matters, you need server-side tracking of consumed tokens, a nonce, or an accepted design decision that replay is not worth preventing. Be explicit about which one you chose.
Secrets and Configuration
Secrets belong in the Workers secret store, set via wrangler secret put, never in wrangler.toml and never in source control.
export interface Env {
STATE_SIGNING_KEY: string;
RATE_LIMIT_KV: KVNamespace;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// env.STATE_SIGNING_KEY is injected at runtime, never bundled.
},
};Use a different signing key per environment. A leaked staging key must not be able to forge production tokens, and reusing one key across environments means a single leak compromises everything at once.
Determinism Instead of Storage
One more pattern worth naming, because it eliminates state rather than securing it.
If every client needs the same daily puzzle, you do not need a database row. Derive the answer from a seed both sides agree on:
function answerForRound(seed: string, datasetVersion: number, poolSize: number): number {
// Deterministic: same inputs always produce the same index, on every
// edge location, with no shared storage at all.
let hash = 2166136261;
const input = `${seed}:${datasetVersion}`;
for (let i = 0; i < input.length; i++) {
hash ^= input.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
return Math.abs(hash) % poolSize;
}Including the dataset version means that changing the underlying data changes the answers, rather than silently corrupting rounds already in progress.
The security caveat: determinism is not secrecy. If the client knows the seed and the algorithm, it can compute the answer. Keep derivation server-side when the answer must stay hidden.
Practical Checklist
- Every input parsed through a
.strict()schema with explicit bounds - Generic error responses; detailed errors logged, never returned
- Rate limiting matched to stakes: built-in or KV for abuse, Durable Objects for hard limits
429responses carryRetry-After- Signed state carries an expiry and a version, and contains nothing secret
- Constant-time verification via
crypto.subtle.verify - Separate signing keys per environment, stored as secrets
- CORS restricted to known origins rather than
*on any endpoint that mutates state
Summary
Edge runtimes reward designs that avoid shared mutable state. Validation with Zod is the cheap, non-negotiable baseline. Rate limiting requires choosing consistency deliberately, since the convenient options are eventually consistent and the exact one costs latency.
Signed state is the technique that makes the rest work: it lets a client carry its own data without being trusted, which removes the need for per-user server storage entirely. Use it knowing exactly what it provides, integrity and authenticity, and what it does not, confidentiality and replay protection.
Used In
Wordle-style web game for developers where players identify a hidden technology using multi-attribute feedback and directional year hints.
A circular puzzle game that rethinks falling-block gameplay across a radial 2D board and a fully rotatable 3D globe, with handcrafted levels, progression, online services, and a cosmetic economy.