How-to › Authenticate and authorize calls

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.

Audience
API consumer
Level
intermediate
Topic
Send API keys and bearer tokens
Languages
TypeScript
Verified

Your page calls the weather provider straight from the browser, with the key in a header. The build inlined it, so the key sits in a file any visitor can download, and the network tab shows it on every request. The provider bills your account for whatever anyone does with it, and rotating it means a redeploy.

What you get

You will end up with a route on your origin that adds the key server side, refuses other sites, and a test that searches the bundle for it. This is for you if a page you serve calls an API whose key must stay private.

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 defines it, and the check below compares all three. If the route will run at the edge, the Cloudflare Workers secrets page covers wrangler secret put, which is how the key reaches env there.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
An edge function that injects the keyThe site is static and the only server you want is the one nearest the userA second runtime with its own secret store, its own local emulator, and a handler your app’s debugger cannot reachYou already run a backend on the page’s origin
A publishable key with origin restrictionsThe provider issues one, and what it permits is all the page needsThe restriction is a check on the Referer header, which stops other websites and does not stop a terminal, so a quota does the real limitingThe provider has no publishable key, which is most providers
A same-origin backend routeYou run a server already, and the page is on its originOne extra hop per call, and a route that rate limiters and attackers both find, so it needs a session or a limit of its ownThe page is static and there is no server behind it
Short-lived session tokens minted server sideThe provider accepts a token you mint, or the browser calls a service of yours that doesA minting endpoint to protect, a clock to agree on, and a token that keeps working for its whole life after captureThe 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.

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.

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 adds Sec-Fetch-Site, whose value is same-origin, same-site, cross-site, or none for an address the user typed.1 Origin travels with every cross-origin request and every request that is not a GET or HEAD.

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, with the key arriving on env instead of process.env.

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 and Vercel 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.

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 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.

node demo.mjs
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.

node --test proxy.test.mjs
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 referenced from client code, and Next.js does the same for NEXT_PUBLIC_. The prefix is the documented signal that the value is public, and a secret behind it is public too.

node pitfall.mjs
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 and a quota on such a key instead, and read the provider’s page on which keys are safe to expose 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 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.

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.

Footnotes

  1. The header’s name is what makes it trustworthy. The Fetch standard reserves every name beginning with Sec- as a forbidden request header, one a script may not set through fetch or anything like it. The Fetch Metadata specification 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. ↩︎ Back to text

  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. 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 adds that browsers may withhold the Referer header for privacy reasons, which is the header the restriction reads. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.