Your service calls one API from nine places. Six of them set the Authorization header, three were added later by somebody reading a different example, and the API answers those three with 401. Setting the header at a tenth call site is the same bug waiting for its turn.
What you get
You will end up with one function that carries the token to a single API origin, and a test that proves calls to any other host go out without it. This is for you if you call an API from several places and have no SDK.
Short answer
Wrap fetch in a function that sets the Authorization header when the request URL matches the API origin, then route every call through it. The origin check keeps the token off requests to other hosts, including a redirect target. Three ways to build the wrapper are compared below, and all three keep headers the caller passed.
You will need
Node 22 or later, and a bearer token for the API. The header format is RFC 6750, which is one scheme name, one space, and the token: anything else is a different scheme and the server is entitled to reject it.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A wrapper around fetch | One or two APIs, no dependency you have to track | Every call site has to route through it, and one direct fetch bypasses the whole scheme | Code you cannot edit makes the calls |
| An undici interceptor | A dependency makes its own calls and offers no header option | It reaches every caller in the process, so an origin check is the only thing keeping the token in place | You control the call sites and want the scope visible |
| ky | You want retries, timeouts and hooks along with the header | A dependency to upgrade, and its hook order is one more thing to hold in your head | The header is all you need |
| The header on each call | A script with two requests in it | Nothing enforces it, so the ninth call site is the one that ships without a token | More than a handful of call sites |
The wrapper and the interceptor differ in blast radius rather than in effort. A wrapper covers the calls you route through it and misses the ones you forget. An interceptor covers every call in the process, including calls made by dependencies. That is the reason to use it, and the reason it needs an origin check before it is safe.
Set the header in one function
The wrapper takes the token and the API origin, and returns a function with the same shape as fetch.
export function makeClient({ token, origin }) {
const allowed = new URL(origin).origin
return async function call(input, init = {}) {
const url = new URL(typeof input === 'string' ? input : input.url, allowed)
const headers = new Headers(init.headers ?? (typeof input === 'string' ? undefined : input.headers))
// The origin check is the whole safety property: a token attached to every
// request follows a redirect off the API and onto somebody else's host.
if (url.origin === allowed) {
headers.set('authorization', `Bearer ${token}`)
}
return fetch(url, { ...init, headers, redirect: 'error' })
}
}
Three decisions are packed into those lines. A relative path resolves against the API origin, so a
call site writes /v1/things rather than repeating the host. The caller’s headers are copied into
a Headers object before the token is
set, so a request id passed by the caller survives. And redirect: 'error' refuses to follow a
redirect at all, because a redirect is where a token leaves the host you meant it for.
Keep the token off every other host
The origin comparison uses URL.origin, which is scheme, host, and port together. A token scoped to
https://api.example.com does not go to http://api.example.com, and it does not go to
https://api.example.com.attacker.example, because neither string matches. String prefix checks
fail that second case, which is why the code parses the URL instead of comparing text.
One token per origin is the rule that follows. A service calling three APIs holds three clients, each closed over its own token, rather than one client with a lookup table. That costs three lines of construction. It also removes the question of which token a given call carries, and that is the question a stack trace will not answer for you.
Refusing redirects is the stricter half of the same rule. The default fetch behavior follows up to
twenty redirects and re-sends your headers to wherever it lands. The specification leaves header
stripping across origins to the implementation rather than requiring it. If the API you call does
redirect legitimately, set redirect: 'manual' and re-enter the wrapper with the new URL, so the
origin check runs again for the new host.
Check it worked
Assert on what arrived at the server rather than on what the client sent, because the two differ exactly when a header is dropped somewhere in between. This test server records the Authorization header of every request.
test('every call to the API origin carries the token', async () => {
await call('/v1/things')
await call('/v1/things', { method: 'POST', body: '{}' })
assert.deepEqual(api.seen.map((r) => r.auth), [
'Bearer sk-test-1',
'Bearer sk-test-1',
])
})
test('a call to any other origin does not', async () => {
await call(`${other.url}/v1/things`)
assert.equal(other.seen.at(-1).auth, null)
})
node --test token.test.mjs
1..3
# tests 3
# suites 0
# pass 3
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 279.680412
Two servers, one token, and the second server never sees it.
When it goes wrong
The header vanishes for one call site and nobody can see why. Passing a
Request object and an init object to
fetch together replaces the Request’s headers rather than merging them, so a wrapper that reads only
init.headers loses whatever the Request carried. The wrapper reads both, which is what the
conditional in the Headers constructor is doing.
The second failure is a call that never reaches the wrapper. Both requests below ask the same server for the same thing, and only one of them is authenticated.
node pitfall.mjs
/merged authorization: Bearer sk-test-1
/replaced authorization: none
Nothing in the client’s own logs marks the second line as a mistake. A grep for fetch( in review,
or a lint rule banning the global inside your service directory, catches it before the 401 does.
When not to do this
Do not build a wrapper for an API that already has a maintained client. A generated or vendor SDK handles the header, the retries and the error types together, and replacing it with twelve lines means owning all three.
Do not put a bearer token in a wrapper that runs in a browser. Any token in client-side code is readable by anyone who opens the network tab, whatever the wrapper does with it. Move the call to a server you control and let the browser talk to that.
Do not use this shape for credentials that expire during a process. The wrapper here holds one string for its lifetime, which suits a long-lived API key and does not suit an access token with a five-minute life. That needs a refresh path, and refreshing under concurrency has a failure of its own.
Do not widen the origin check to make a staging host work. Two origins mean two clients, each with its own token, which is also what keeps a staging token from reaching production.
Related how-tos
Last verified
Verified 2026-09-06 against Node 22.22.2. Every output block is what the preceding command printed, run against a local server on the loopback interface rather than against a live API.