7 min read

A public API key is not authorization

Every embedded widget ships a key in plain sight. Here are the five layers that have to stand behind it before that key can be allowed to write anything.

If your product embeds a script on other people's websites, you have shipped a credential to the public internet. Anybody can view source and take it. This is not a flaw to be fixed — it is the nature of the deployment model — but it does mean the key cannot be the thing that authorizes a write.

It is worth being precise about what the key actually is. It is an identifier, not a secret. It says which project a request belongs to. Everything that decides whether the request is allowed has to sit somewhere else.

Layer one: the origin allowlist

Browsers set the Origin header on cross-origin requests and page JavaScript cannot override it. That makes it a real signal for requests that genuinely come from browsers, which is the overwhelming majority of the traffic an embedded widget sees.

Requiring an exact origin match — with wildcards permitted at one subdomain level for preview deployments — means a key lifted from someone's page source is useless from any other site in any browser.

It is not a defence against a scripted HTTP client that sets whatever header it likes. Which is exactly why it is layer one of five and not the whole design.

Layer two: rate limits on two axes

One limit is not enough. A per-submitter limit stops one person hammering the endpoint but does nothing against a distributed flood. A per-project limit caps the blast radius of a flood but would punish a busy site's real users if it were the only control.

Run both. Identify the submitter by a salted hash of their IP rather than the address itself — you get the accounting you need and store nothing you would have to defend later.

Layer three: bot heuristics that teach nothing

A hidden honeypot field that no human ever fills in, and a minimum time-on-form below which no human could have written a real report. Cheap, no CAPTCHA, no third-party script.

The detail that matters: answer both with a success response. Returning "rejected: honeypot" tells automation precisely which check to defeat. Returning the exact 201 a real report gets, and quietly discarding the submission, tells it nothing at all.

Layer four: validate against your own definitions

If your product supports custom fields, do not accept the field map as sent. Load the project's saved field definitions and validate against those — reject keys you never defined, reject select values outside the options you configured, coerce numbers, enforce required.

Otherwise your JSONB column is an unbounded write surface with a friendly name.

Layer five: the database does not trust the application

Row level security, enabled on every table, with policies keyed off membership rather than a client-supplied identifier. A missing filter in one query then cannot leak another tenant's data, because the rows are not returned.

The service-role key that bypasses RLS belongs on exactly the paths that have already done their own authorization — the ingest route, after all five layers have passed. Never as a general-purpose client.

The test

Assume the key is public, because it is. Then ask what an attacker holding it can actually do. If the answer is "submit feedback to the site it was issued for, at a bounded rate, into fields the owner defined", the design is doing its job.