← Back to Writing

Published 02/05/2026

Why I Built a Submission Gateway

#system-design #infrastructure #security #edge-computing

Background #

Over the last few years I've gone through a handful of personal site CMS implementations. I've gone from Markdown to custom CRUD to Payload CMS. I've now landed on a Cloudflare Workers + Hono custom CMS backend combined with Refine for quick frontend CRUD. View more on that project here.

My personal site uses Astro SSG and is deployed to Cloudflare Pages. Because it’s statically generated, I can safely use API keys in my CMS without exposing them to client-side code when fetching content. The CMS also handles contact form submissions from the site, which I wanted to protect with secure API keys as well. The problem is that doing this would require some kind of server-side function that client-side code could call, so it could attach the API key and forward the request to the CMS.

Long story short, SSG secures the content fetching but leaves forms exposed. Enter Threshold.

At a high level, Threshold is a standalone submission gateway service that accepts requests, handles security and initial validation, and forwards the requests to their destination. It is generic and config driven. For example, my personal site is registered in the config with the CMS destination, API key configuration, and Turnstile captcha settings. Adding a new sender or destination is as simple as updating the base config.

Standalone vs Coupled #

Why not use a Cloudflare Pages Function, or deploy the whole thing on Workers and use a Worker? Why build a separate service at all?

  1. Experimentation. I have been exploring Cloudflare's stack over the last year or so, and this was an opportunity to work with their rate limiting service as well as Turnstile, which I had only used briefly before.
  2. Separation of concerns. My frontend is focused on being a frontend. The CMS is focused on managing and serving content. Threshold handles the in-between and moves the risk surface from the CMS or any other destination to itself. If the submission endpoint is being hammered with bogus requests and only 1% of them are legitimate, the destination only sees that 1%. In the case of the CMS, this means it is still able to effectively do its job as a CMS.
  3. Extensibility. Threshold is a long-term infrastructure solution for my personal/side projects moving forward. Any frontend can use it to communicate with any backend. It's generalized by nature — the personal site contact form was the first use case, but Threshold doesn't know or care about that.

How It Works #

Request Lifecycle #

When a request arrives at Threshold, it passes through several middleware layers before reaching its destination. Each submission is defined by a configuration that specifies allowed origins, authentication, and destination properties.

Request Lifecycle

Request Client submits a request to Threshold at a /submissions/:submission endpoint.

CORS The request's origin is checked against the allowed origins for this submission.

Auth Each submission has an associated API token. Clients send the raw token in the X-API-Key header, but Threshold never stores raw tokens. Instead, it stores an HMAC-SHA256 hash of each token using a server-side secret key. When a request comes in, Threshold recomputes the HMAC using the same secret key and compares it to the stored hash. If they match, the request is authenticated.

This is a stateless approach with no database lookups and no session management. It just uses cryptographic comparison. It also means the config file doesn't contain sensitive secrets - if someone got access to the config, they'd just have hashes, not usable keys. Generating a new key is a simple script that outputs the raw key for the client and the hash for the config.

Validation This is intentionally kept lean. The goal is for the destination app to handle business logic and its relevant validation. Threshold only validates the request's body structure to avoid forwarding completely invalid requests.

Turnstile If configured, the request must pass Turnstile CAPTCHA verification based on the requesting user's IP. This is intentionally handled before rate limiting.

Rate Limiting Requests are rate limited using Cloudflare's Worker Rate Limiting API after Turnstile to ensure we are only rate limiting real requests not including bots.

Forwarding The body is forwarded to the destination endpoint using an auth key specific for that destination. The forwarder also supports using Service bindings if the communication is between Workers, or standard fetch requests otherwise.

The auth key mentioned here is the same one I originally wanted to keep away from client side code.

Tech Stack #

  • Cloudflare Workers: The runtime Threshold is built on. Workers provide a globally distributed, low-latency execution environment that’s well suited for request handling and edge security. They’ve become my go-to lately due to excellent pricing, minimal cold starts, and access to the broader Cloudflare ecosystem of tools.
  • Hono: A lightweight web framework for Workers that provides routing, middleware, and a clean API with very little overhead. It’s easy to use, has first-class support for Workers, and almost feels like the de facto way to write Workers code given how naturally it integrates.
  • Cloudflare Turnstile: Cloudflare’s CAPTCHA alternative used to distinguish real users from bots without adding unnecessary friction. It integrates cleanly with Workers and takes a more privacy-minded approach compared to solutions like Google reCAPTCHA.
  • Workers Rate Limiting API: Cloudflare’s native rate limiting solution for Workers, used to enforce request limits at the edge as part of the request lifecycle.
  • @noble/hashes: A small, well-known cryptography library used for hashing API keys. Using a tested and widely trusted implementation avoids reinventing cryptographic primitives and reduces the risk of subtle security mistakes.
  • Biome: A formatter and linter used to keep the codebase consistent and readable. I’ve been comparing how it feels versus ESLint + Prettier, and I enjoy having a single combined tool rather than maintaining two separate ones.

Configuration #

type SubmissionConfig = {
  name: string;
  apiKey: string; // used with a key secret to confirm client key
  destination: {
    binding?: string;
    url: string; // env variable
    authHeader?: string; // env variable
  };
  turnstileRequired: boolean;
  origins: string; // env variable for comma separated list
};

This config is stored as an object with the key being the name and the value being the config for quick lookups. One detail that invites future changes (as described in the Future Considerations section below) is that the secrets and other environmental values are actually stored as environment variables with the values in the config representing the key to use to access them. The benefit here is that I don't have to check in any secrets to source control as they are provided at runtime. The downside is I will need 3-4 environment variables per submission type that is configured - the list of required environment variables could quickly become hard to manage as the application list grows.

Future Considerations #

Submission Durability
This is a top feature I'd like to add to improve the overall resiliency of Threshold. As it stands, if the forwarded request fails to reach the destination for any reason, the whole submission fails. This is basically fine for contact form submissions, since the user would be made aware of the failure immediately and be able to retry directly. In future implementations, however, I may want to be able to trigger submissions from background processes or support fire-and-forget scenarios where there is no direct feedback to be given and quietly failing would be detrimental.

Dynamic Config
This is primarily a convenience improvement, especially since Threshold is a personal tool and thus not made for a long list of application configs. Even still, moving the whole config into some sort of database does give me the ability to rotate keys and perform other config changes at runtime without needing to redeploy. I think I would likely reach for Cloudflare KV, which offers quick reads that are important for a gateway and dynamic data that may fit better with application configuration data than a SQL database. I would get the added benefit of being able to use bindings and low-latency operations by staying within the Cloudflare ecosystem as well. KV does have the tradeoffs of eventual consistency and cold cache scenarios. I could consider Cloudflare D1 to get around these, but I think the use case of rare updates that are not needed within seconds makes these tradeoffs acceptable.