← Journal
Build log / 06 · 7 min read

Shipping an API key in public without getting burned

Connecting a static site's contact form to an API sounds trivial until you notice the shape of the problem: the credential has to live somewhere, and on a static site every possible hiding place is public.

This is the constraint DodoForm's browser keys exist to solve, and working through it clarified what actually makes a public credential safe.

The first instinct is to have the page post straight to the API with a key. Two things stop it.

The first is the key itself. A secret key in client-side JavaScript is not hidden by minification or by living in an environment variable at build time — it is in the bundle, and anyone can read it. A key that can reach every endpoint in your account should never be there.

The second is quieter and catches people out. Cross-origin browser requests need the server to opt in with CORS headers, and a JSON POST triggers a preflight first. We checked, and the preflight from an unregistered origin comes back with no Access-Control-Allow-Origin header at all. The browser then refuses to send the real request. It does not matter how correct your code is; the call cannot happen.

Given that, there are exactly two designs, and which one is right depends on whether you have a backend.

If you have somewhere to run code, put the secret there. The page posts to your own endpoint, your endpoint adds the key and calls the API server-to-server, where CORS is irrelevant because no browser is involved. This is what our contact form does. It also gives you a place to validate input, drop obvious spam, and log failures without leaking upstream details to visitors.

If you have no backend at all, the secret approach is impossible, and this is where a purpose-built public key belongs.

A browser key is designed on the assumption that everyone can read it. That assumption changes what the key is allowed to do, and the restrictions are the whole design:

It reaches one endpoint, not the whole API. It is bound to a single destination decided when the key is created, so a form value naming a different destination is ignored rather than trusted — otherwise anyone reading your page source could redirect submissions or create new destinations. It accepts structured data only, never free-text extraction, because extraction spends credits and a public key could be used to drain them. And it works only from the origins listed on the key.

That last restriction is the one that makes the difference, and it is worth understanding precisely. Because a preflight cannot carry the key, the server has to decide from the Origin header alone whether to permit the call. Which means the allowlist has to be exact: the scheme is part of the origin, so the http and https versions of a host are different entries, and a wildcard for subdomains does not cover the bare domain. Get it wrong and you get a permission error rather than a mysterious silence, which is the right way for that to fail.

There is a real cost to the public-key route beyond the setup. A browser key deliberately cannot add new fields to an existing destination, so that someone abusing it cannot litter your records with junk columns. Values you send that do not match a known field are stored, but off to the side.

Practically, that means the destination has to be fully defined before the first submission arrives. With a server-side key the fields are inferred from the first payload and you can be sloppy. With a browser key, sloppiness costs you data in the wrong place.

Ask one question: do I have a server?

If yes, use a secret key server-side. It is more capable, there is nothing to allowlist, and the credential is never exposed.

If no, use a key designed to be public and accept its limits. What you must not do is take a secret key and put it in the browser because it was quicker. The two kinds of key look similar and are not remotely equivalent, and only one of them is safe to read.

Follow on X →