# How to call a keyed API from a browser without shipping the key

> Put the provider's key on a route you control, refuse that route to other sites, and search the bundle for the secret before every release.

Source: https://voxgig.com/howto/keep-an-api-key-out-of-browser-code

- Audience: api-consumer
- Level: intermediate
- Languages: typescript
- Verified: 2026-09-24
- Published: 2026-09-24

## Short answer

Move the call to a route on your own origin. The page asks `/api/weather`, the route adds the key from the server's environment and forwards one query parameter, and the browser never holds a secret. Check `Sec-Fetch-Site` and `Origin` so a script on another site cannot use the route, and search the built bundle for the key before every release.

---
## You will need

Node 22 or later, the provider's key, and the origin your page is served from. Verified
2026-09-24 against Node 22.22.2. An origin is a scheme, a host, and a port together, as
[RFC 6454](https://www.rfc-editor.org/rfc/rfc6454) defines it, and the check below compares all
three. If the route will run at the edge, the Cloudflare Workers
[secrets](https://developers.cloudflare.com/workers/configuration/secrets/) page covers
`wrangler secret put`, which is how the key reaches `env` there.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [An edge function that injects the key](https://developers.cloudflare.com/workers/runtime-apis/handlers/fetch/) | The site is static and the only server you want is the one nearest the user | A second runtime with its own secret store, its own local emulator, and a handler your app's debugger cannot reach | You already run a backend on the page's origin |
| [A publishable key with origin restrictions](https://developers.google.com/maps/documentation/javascript/get-api-key) | The provider issues one, and what it permits is all the page needs | The restriction is a check on the `Referer` header, which stops other websites and does not stop a terminal, so a quota does the real limiting | The provider has no publishable key, which is most providers |
| [A same-origin backend route](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Sec-Fetch-Site) | You run a server already, and the page is on its origin | One extra hop per call, and a route that rate limiters and attackers both find, so it needs a session or a limit of its own | The page is static and there is no server behind it |
| [Short-lived session tokens minted server side](https://www.twilio.com/docs/iam/access-tokens) | The provider accepts a token you mint, or the browser calls a service of yours that does | A minting endpoint to protect, a clock to agree on, and a token that keeps working for its whole life after capture | The provider takes only its own key, so the token buys nothing |

A backend route and an edge function do the same job from different places. The route is a few
lines in a server you already debug, and it adds a hop to wherever that server is. The edge
function runs near the user and costs you a second runtime with its own secret store and its own
emulator. A publishable key removes the hop and exists only where the provider offers one. Its
restriction is a `Referer` check, so the quota you set beside it is what limits the bill.

## Add the key on the server

The route builds the upstream request from scratch, with the key from the process environment
and one named parameter from the page. Nothing the browser sent is forwarded.

```ts title="proxy.mjs"
if (url.pathname === '/api/weather') {
  if (!isSameSite(new Headers(req.headers), siteOrigin)) {
    res.writeHead(403, { 'content-type': 'application/json' })
    return res.end(JSON.stringify({ error: 'cross-site request refused' }))
  }
  // Build the upstream request from scratch. Forwarding the browser's headers would
  // forward its cookies, and forwarding its query string would let it pick the endpoint.
  const target = new URL('/v1/weather', upstream)
  target.searchParams.set('city', url.searchParams.get('city') ?? '')
  try {
    const r = await fetch(target, { headers: { 'x-api-key': apiKey } })
    const body = await r.text()
    res.writeHead(r.status, { 'content-type': 'application/json' })
    return res.end(body)
  } catch {
    // A provider that cannot be reached is a 502 from this route, not an exit of the process.
    res.writeHead(502, { 'content-type': 'application/json' })
    return res.end(JSON.stringify({ error: 'upstream unavailable' }))
  }
}
```

`apiKey` is read from `process.env.UPSTREAM_KEY` once, when the server starts, and the server
refuses to start without it. There is no default value in the source, because a default is a key
in the repository.

The page knows the route and nothing else.

```js title="public/app.js"
export async function loadWeather(city) {
  const res = await fetch(`/api/weather?city=${encodeURIComponent(city)}`)
  if (!res.ok) throw new Error(`weather route answered ${res.status}`)
  return res.json()
}
```

A view-source of this file tells a visitor which route to call, which the network tab shows
anyway.

## Refuse other sites at the route

A route with the key behind it is useful to any page on the web, so the route has to know which
page is asking. Two request headers say so, and a script cannot remove or forge either. [Fetch
Metadata](https://w3c.github.io/webappsec-fetch-metadata/) adds
[`Sec-Fetch-Site`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Sec-Fetch-Site),
whose value is `same-origin`, `same-site`, `cross-site`, or `none` for an address the user
typed.[^1] [`Origin`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Origin)
travels with every cross-origin request and every request that is not a GET or HEAD.

```ts title="origin.mjs"
export function isSameSite(headers, siteOrigin) {
  const site = headers.get('sec-fetch-site')
  if (site === 'cross-site' || site === 'same-site') return false
  const origin = headers.get('origin')
  if (origin && origin !== siteOrigin) return false
  return true
}
```

Only the two values that name another site are refused. The specification asks servers to ignore
a value they do not recognize, so an unknown value passes here and the `Origin` comparison still
runs. `same-site` is refused on purpose, so a page on another subdomain of your domain gets
the same 403 as a stranger. If one of your subdomains needs the route, allow that value and
compare `Origin` against a list.

A request carrying neither header is allowed through. curl sends neither, and neither does a
browser on a plain `http://` origin, because Fetch Metadata is set only for URLs the browser
considers trustworthy. This check keeps other websites out. It is not authentication, and a route
that must also keep terminals out needs a session or a rate limit as well.

## Run the same route at the edge

The edge version is the same route in the shape a Workers runtime calls: one
[fetch handler](https://developers.cloudflare.com/workers/runtime-apis/handlers/fetch/), with
the key arriving on `env` instead of `process.env`.

```ts title="edge.mjs"
export default {
  async fetch(request, env) {
    const url = new URL(request.url)
    if (url.pathname !== '/api/weather') return new Response('not found', { status: 404 })
    if (!isSameSite(request.headers, url.origin)) {
      return Response.json({ error: 'cross-site request refused' }, { status: 403 })
    }
    const target = new URL('/v1/weather', env.UPSTREAM)
    target.searchParams.set('city', url.searchParams.get('city') ?? '')
    try {
      const r = await fetch(target, { headers: { 'x-api-key': env.UPSTREAM_KEY } })
      return new Response(await r.text(), {
        status: r.status,
        headers: { 'content-type': 'application/json' },
      })
    } catch {
      return Response.json({ error: 'upstream unavailable' }, { status: 502 })
    }
  },
}
```

`isSameSite` is the same function, because `request.headers` is a web `Headers` object in both
places. The demo runs this file under a short Node harness, `run-edge.mjs`, that turns an
incoming request into a `Request` and writes the `Response` back. That is enough to test the
handler. It is not the `workerd` runtime, so run it under `wrangler dev` before deploying it, and
expect the same shape on [Netlify](https://docs.netlify.com/build/edge-functions/overview/) and
[Vercel](https://vercel.com/docs/functions) with their own names for `env`.

## Mint a token the browser can hold

When the provider accepts a token you sign, as Twilio does for its client SDKs, the browser
holds that instead of the key. The token names one user and one expiry, and the signature stops
the browser changing either.

```ts title="token.mjs"
export function mint(secret, { sub, ttlSeconds = 300, now = Math.floor(Date.now() / 1000) }) {
  const payload = Buffer.from(JSON.stringify({ sub, exp: now + ttlSeconds })).toString('base64url')
  const sig = createHmac('sha256', secret).update(payload).digest('base64url')
  return `${payload}.${sig}`
}
```

The route that mints it sits behind the same origin check and behind whatever session your app
already has. Five minutes is the default life here, and Twilio's default is an hour. A captured
token is worth that long and no longer, which is the gain over a key that is worth everything
until someone rotates it. The verifier compares the signature
with [`timingSafeEqual`](https://nodejs.org/api/crypto.html) and checks the expiry against a
clock passed in, so a test can move time without waiting.

## Check it worked

The demo starts the provider stand-in, the backend route, and the edge handler on the loopback
interface, then asks the three questions the page is about.

```bash
node demo.mjs
```

```text output
key in the browser bundle:           not found (app.js, index.html)
key in the browser's request:        not found (GET /api/weather)
key in the provider's request:       x-api-key present, added by the route
same-origin page, backend route      200 {"city":"Cork","tempC":17}
evil.example, backend route          403 {"error":"cross-site request refused"}
same-origin page, edge handler       200 {"city":"Cork","tempC":17}
evil.example, edge handler           403 {"error":"cross-site request refused"}
session token, 10s later             ok, sub user_42, 290s left
session token, 301s later            refused: expired
session token, payload edited        refused: bad signature
```

The first three lines are the search. The key is generated fresh on each run, so a hit cannot be
an accident. Every file under `public/` is read from disk and fetched from the server, the route
records the request the browser made, and the stand-in records what the provider received. The
key appears in the last of the three and nowhere else.

The tests assert the same things, and two more: a refused request never reaches the provider,
and a downed provider is a 502, not a crash.

```bash
node --test proxy.test.mjs
```

```text output
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 240.938441
```

Run the same search against your real build output in CI, with the live key taken from the
deployment environment, and fail the build on a match.

## When it goes wrong

The key is in the bundle, though no person put it there. A build-time variable did. [Vite inlines any
`VITE_` variable](https://vite.dev/guide/env-and-mode) referenced from client code, and Next.js
does the same for
[`NEXT_PUBLIC_`](https://nextjs.org/docs/app/guides/environment-variables). The prefix is the
documented signal that the value is public, and a secret behind it is public too.

```bash
node pitfall.mjs
```

```text output
the built bundle, searched for the key
  found at byte 186: const KEY = "sk_live_9f3c...

the request that bundle makes, as the network tab shows it
  GET https://api.weather.example/v1/weather?city=Cork
  x-api-key: sk_live_9f3c...

who can read it: anyone who loads the page, and anyone who fetches the bundle URL
```

Drop the prefix and read the variable on the server only. Treat the key as leaked and rotate it
rather than removing it from the source, because every cached copy of the bundle still carries it.

The route answers curl from anywhere. No `Sec-Fetch-Site` and no `Origin` arrive from a
terminal, so the check passes, which is what it is designed to do. Put the route behind the
session your app already has, or behind a per-client rate limit, and set a quota at the provider.

The route is an open proxy. It forwarded `req.url` to the provider, or copied the browser's
headers into the upstream request, so anyone can reach every endpoint the key unlocks and send
your users' cookies along. Build the upstream request from named parameters, as the sample does.

## When not to do this

Do not build a route in front of a key that was designed to be shipped. Stripe's `pk_` keys and
Google Maps JavaScript keys exist so a page can call the provider directly, and each permits only
what a page should do. Put the [referrer restriction](https://developers.google.com/maps/api-security-best-practices)
and a quota on such a key instead, and read the provider's page on
[which keys are safe to expose](https://docs.stripe.com/keys) before deciding a key is one of
them.[^2]

Do not treat the origin check as authentication. It tells other websites' scripts no. It tells
a terminal nothing, and a route that must not be scriptable needs a session behind it.

Do not mint a token that lives for a day because five minutes is inconvenient. The life of the
token is the size of the loss when one is captured, and a renewal endpoint is cheaper than a
long life.

Do not keep the key in a repository, an image layer, or a client-side variable while you test.
[The OWASP secrets guidance](https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html)
puts secrets in the environment at run time for a reason, and the first commit with a key in it
is the one that gets cloned.

## Related how-tos

- [Add a bearer token to fetch without a client library](/howto/bearer-token-fetch-wrapper)

- [Keep test and live API keys from crossing environments](/howto/keep-test-and-live-keys-apart)

- [Rotate API keys without breaking your clients](/howto/rotate-api-keys-without-breaking-clients)

## Last verified

Verified 2026-09-24 against Node 22.22.2. Every output block is what the command preceding it
printed, against servers on the loopback interface rather than a live provider. The edge handler
ran under the Node harness in `run-edge.mjs`, not under `wrangler dev` or a deployed Worker: the
handler shape is the Workers fetch handler, and the runtime is not. The Stripe, Google Maps, and
Twilio rows describe documented behavior and were not run.

[^1]: The header's name is what makes it trustworthy. The Fetch standard reserves every name
beginning with `Sec-` as a
[forbidden request header](https://developer.mozilla.org/en-US/docs/Glossary/Forbidden_request_header),
one a script may not set through `fetch` or anything like it. The Fetch Metadata
[specification](https://w3c.github.io/webappsec-fetch-metadata/) put its four headers behind
that prefix and got a header nobody can forge for the price of four letters. While the
specification remains an Editor's Draft at the W3C, with a Working Draft behind it, every major
browser has shipped it, which is the usual order of events.

[^2]: Stripe's page on keys explains how to expire a secret key and a restricted key. Between
the two instructions sits a note saying
[a publishable key cannot be expired](https://docs.stripe.com/keys). A key made to be shipped in
a page cannot be recalled from one, so rotation is the only remedy. Every cached copy of the
page keeps the previous value until the cache lets go of it. Google's page on
[key restrictions](https://developers.google.com/maps/api-security-best-practices) adds that
browsers may withhold the `Referer` header for privacy reasons, which is the header the
restriction reads.