Your Bridge UUID lives in the browser. Origin allowlists block any domain you didn't list with a 403, stopping strangers who copy-paste your URL.

Open a network tab on any frontend that calls SaltingIO and the URL sits right there. https://api.salting.io/r/{uuid} lives in your bundle, your <script> tags, and every fetch call you make from the browser. That's the design. The point of a Bridge is to swap a real API key for a UUID the browser can hold without leaking anything dangerous. But that UUID is now public. What stops a stranger from copying it into curl and burning through your monthly quota?
The answer is origin allowlists, and they're worth understanding before you ship anything that lives on a public domain.
Every browser request carries an Origin header. The browser sets it automatically based on the page making the request, and you can't fake it from inside another page without the user pasting attacker-controlled JavaScript into their own console. SaltingIO checks this header against the allowlist you configured for the Bridge. If the request comes from a domain you didn't list, the response is:
{"error":"Origin not allowed","message":"Your origin is not authorized to access this endpoint."}
with a 403. Not a 200. Not a partial response. The upstream API never gets called, your quota doesn't move.
What this won't catch: a server-side request from an attacker's machine that omits the Origin header entirely, or that sends an Origin header pretending to be your domain. That's why origin lock is one layer, not the only layer. If your record is also private (X-API-Key required) or password-protected, the attacker still needs to find the key. For a Bridge that wraps a paid third-party API, layering both is reasonable. For a Bridge that wraps a public read-only endpoint, origin lock alone usually does the job of cutting off casual abuse from someone who copy-pasted your UUID.
When creating or editing a Bridge in the dashboard, the form has an "Allowed origins" field. This is a list, not a single value, because real apps have more than one origin: production at https://yourdomain.com, a staging environment at https://staging.yourdomain.com, and http://localhost:5173 while you're developing. List each one explicitly. Wildcard subdomains aren't supported, so *.yourdomain.com won't work. List each subdomain you actually use.
Two practical defaults are worth setting from the start.
Production should list only your live domain. Don't carry localhost into production by accident; an attacker running a local dev server would happily hit your Bridge from http://localhost:3000 if you left it in.
Staging and prod should be separate Bridges with separate UUIDs and separate allowlists. Same upstream URL, different records. This way you can rotate one without disturbing the other, and a leaked staging UUID can't be used against your prod quota.
A typical Bridge call from a real frontend looks like this:
async function getRecommendations(userId) {
const res = await fetch(
`https://api.salting.io/r/${BRIDGE_UUID}?user_id=${userId}`
);
if (!res.ok) {
if (res.status === 403) throw new Error('Origin blocked');
if (res.status === 429) throw new Error('Rate limited, slow down');
throw new Error(`Bridge call failed: ${res.status}`);
}
return res.json();
}
No header gymnastics. No backend. The browser sends the Origin header automatically, SaltingIO checks it, and the upstream call goes through. Notice the explicit branch on 403. If you ever see "Origin blocked" coming back from a real user, a domain is missing from the allowlist. Most likely it's www.yourdomain.com versus yourdomain.com.
Origin lock is a CORS-style check, which means it covers the browser threat model well and the curl threat model not at all. Anyone with a terminal can send any Origin header they want. So for sensitive Bridges (anything paid, anything that can move money, anything tied to a personal account), combine origin lock with a private access level.
Setting the access level to Private adds an X-API-Key requirement on top of the origin check. Both must pass. The key is shown exactly once at creation, on the success modal. Copy it before you close the tab; it's never displayed again. Store it where your frontend bundler can pick it up but where it isn't committed to git in plaintext.
A private Bridge call adds one header:
const res = await fetch(`https://api.salting.io/r/${BRIDGE_UUID}`, {
headers: { 'X-API-Key': import.meta.env.VITE_BRIDGE_KEY }
});
Don't use Authorization: Bearer here. Bearer tokens are dashboard JWTs only and don't validate on /r/ endpoints. The error you'll get is a 401 with "Authentication required, please provide your token in the X-API-Key header to decrypt the content," which is the kind of error message that's easy to read past three times before noticing what it's actually saying.
Three problems show up on the first deployment more often than any others.
The trailing slash trap. Origins are matched as full strings. https://yourdomain.com is not the same as https://yourdomain.com/. The browser will not send a trailing slash on the Origin header, so list the domain without a slash. Listing it with one means every real request fails with a 403, which sends people down a CORS rabbit hole that has nothing to do with the actual problem.
www versus apex. If your site is reachable at both https://yourdomain.com and https://www.yourdomain.com (a common default with most hosting providers), you need both in the allowlist. Most teams pick one as canonical and 301-redirect the other, which means in practice you only need the canonical one. But the redirect happens server-side after the fetch already started, so the original origin still applies. List both during migration and prune later.
CORS preflight on POST. If your Bridge takes a Content-Type: application/json body, the browser sends a preflight OPTIONS request before the real one. SaltingIO handles preflights, but if you're behind a corporate proxy or a service worker that strips the Origin header, the preflight will fail and the real request never fires. The signature is a network tab full of OPTIONS requests with no matching POSTs. Check the request headers, not just the URL.
For a public Bridge wrapping something free and low-risk (a weather API, a public data source), origin lock alone is reasonable. The cost of someone scraping is bounded by your monthly quota, which the Starter and Builder tiers cap automatically. For anything that costs you money per call (OpenAI, Anthropic), don't ship without a private access level layered on top.
Professional API security without the "Backend Tax."