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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| An edge function that injects the key | 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 | 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 | 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 | 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.
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.
Related how-tos
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
-
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 throughfetchor 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 -
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
Refererheader for privacy reasons, which is the header the restriction reads. ↩︎ Back to text