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

Source: https://voxgig.com/howto/bearer-token-fetch-wrapper

- Audience: api-consumer
- Level: beginner
- Languages: typescript, javascript
- Verified: 2026-09-06
- Published: 2026-09-06

## 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](https://www.rfc-editor.org/rfc/rfc6750), 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](https://developer.mozilla.org/en-US/docs/Web/API/Window/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](https://undici.nodejs.org/#/docs/api/Dispatcher) | 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](https://github.com/sindresorhus/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](https://developer.mozilla.org/en-US/docs/Web/API/Headers) | 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.

```ts title="client.mjs"
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](https://developer.mozilla.org/en-US/docs/Web/API/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.

```ts title="token.test.mjs"
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)
})
```

```bash
node --test token.test.mjs
```

```text output
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](https://developer.mozilla.org/en-US/docs/Web/API/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.

```bash
node pitfall.mjs
```

```text output
/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

- [Refresh an access token once under concurrent requests](/howto/refresh-a-token-once-under-concurrent-requests)

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

- [Diagnose a 401 or a 403 from a credential](/howto/diagnose-401-and-403-from-a-credential)

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