Skip to content
← All posts
5 min read

Handoff had no rate limit. Now it does.

No accounts, no API keys, by design. Which also means anyone who finds the endpoint can hit it, not just our own page.

Our signaling relay was unauthenticated on purpose. Here is what we shipped once we thought through what that actually exposes.

Handoff has no accounts and no API keys, by design: paste text, get a link, done. The signaling relay behind it (`relay.gattyworks.com`) is unauthenticated for the same reason. Which also means it was open to anyone who found the endpoint, not just our own page. We shipped rate limiting and tightened one check that was not actually checking anything.

What being unauthenticated actually exposes

The relay's only job is shuttling a WebRTC handshake between two browsers, then getting out of the way (see the architecture post for the full design). It never sees the shared text. But a signaling endpoint with no login still costs something to run, and once we add TURN, it costs more: a TURN credential is a generic relay grant, usable for arbitrary WebRTC traffic, not just ours. Anyone who can complete a WebSocket handshake to our relay could mint one and use Cloudflare's TURN relay on our account's dime, for whatever they wanted to relay.

Two limits, not one

We added two separate Cloudflare Workers Rate Limiting bindings, both keyed on `CF-Connecting-IP`, which Cloudflare's edge sets from the real TCP connection, not something a client can forge the way it can an `Origin` header.

"ratelimits": [
  { "name": "ROOM_CONNECT_LIMITER", "namespace_id": "1001", "simple": { "limit": 10, "period": 60 } },
  { "name": "TURN_MINT_LIMITER", "namespace_id": "1002", "simple": { "limit": 5, "period": 60 } }
]

`ROOM_CONNECT_LIMITER` (10 per IP per 60 seconds) runs in the Worker's top-level `fetch()`, before a Durable Object is ever touched, the cheapest place to stop room-creation spam. `TURN_MINT_LIMITER` (5 per IP per 60 seconds) is separate and stricter, checked right before the actual credential mint. Tripping it does not fail the connection, it just returns the STUN-only fallback for that attempt. Room joins and TURN mints are gated apart on purpose: someone testing Handoff across a few of their own devices should not also mean unlimited TURN minting once that is live.

Why a rate-limit binding, not another Durable Object

We already had one Durable Object for the signaling room; a second one as a global counter would have worked too. We used Workers Rate Limiting instead: it is declared entirely in `wrangler.jsonc`, no separate resource to provision, and the call site is one line, `const { success } = await env.LIMITER.limit({ key: ip })`. The tradeoff is granularity: windows are fixed at 10 or 60 seconds, nothing in between, so "10 a minute" is as precise as it gets.

It is also not exact, on purpose. Cloudflare's own docs call it "permissive, eventually consistent, and intentionally designed to not be used as an accurate accounting system": each edge location checks a locally cached count and syncs it back out asynchronously, not through one central ledger. We proved this to ourselves after deploying: 13 requests in a row from one script all went through, no 429 anywhere near request 11. That is expected, not broken. A Durable Object counter would have been exact and would have cost real coordination latency on every single room join to get that exactness. For a scripted-abuse deterrent, permissive-but-fast is the right trade, not a bug we need to chase.

The Origin check that was not checking anything

The relay already checked the `Origin` header against an allowlist, but the original code let a *missing* Origin through, commented as a convenience for "non-browser tooling." A real browser cannot lie about its Origin. A script that skips setting the header entirely walks straight past a check meant to keep the relay scoped to our own page. We flipped the default: no Origin now means no connection.

None of this touches the payload. The rate limits gate the signaling handshake, never the WebRTC DataChannel the actual text travels over. "No length limit" on what you can paste into Handoff is still true. We are not doing a global daily circuit breaker either, not yet: per-IP limits already bound any single actor's damage, and Durable Objects' free-tier headroom is generous enough at what this tool actually sees that a global breaker would be solving a problem we do not have.

Next step: this is live on /handoff right now. If you are running an unauthenticated endpoint of your own and want a second pair of eyes on the threat model, write to us at hello@gattyworks.com.

EngineeringCloudflareSecurityCloudflareRateLimitingWebRTCDurableObjectsAppSecCloudflareWorkersSecurityWebDevSoftwareEngineeringInfrastructureAsCode

Ready to ship?

Send a brief. You will get a written reply, a fixed quote, and a delivery date within 24 hours. If we miss that window, the website fee on your first project is refunded in full.

Average reply under 4 hours. Always inside 24.