How-to › Authenticate and authorize calls

How to add a bearer token to fetch without a client library#

Wrap fetch in one function that sets Authorization for a single API origin, so no call site forgets the token and no redirect carries it to another host.

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

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
A wrapper around fetchOne or two APIs, no dependency you have to trackEvery call site has to route through it, and one direct fetch bypasses the whole schemeCode you cannot edit makes the calls
An undici interceptorA dependency makes its own calls and offers no header optionIt reaches every caller in the process, so an origin check is the only thing keeping the token in placeYou control the call sites and want the scope visible
kyYou want retries, timeouts and hooks along with the headerA dependency to upgrade, and its hook order is one more thing to hold in your headThe header is all you need
The header on each callA script with two requests in itNothing enforces it, so the ninth call site is the one that ships without a tokenMore 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.

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.

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