Authenticated Fetch
The header-injection and 401-refresh-retry pattern, and how to build it yourself.
Authenticated requests from inside a WebView follow one pattern: pull headers
from the host, attach them to your request, and on 401 refresh the token and
retry once. bridge.fetch implements exactly this. This page explains the
pattern and shows the manual form for cases where you need more control.
Use bridge.fetch by default
import { bridge } from './lib/bridge'
const res = await bridge.fetch('https://today.ai/feed/api/cards')
const data = await res.json()That single call:
- merges
getHeaders()into the request, - retries once after
refreshToken()if the server answers401, - degrades to a plain
fetchoutside the app.
The manual form
When you need to control header merging, body re-streaming, or the retry policy,
compose the primitives yourself. This is what bridge.fetch does internally:
import { bridge } from './lib/bridge'
async function fetchWithAuth(url: string, init: RequestInit = {}) {
const injected = await bridge.getHeaders()
const withHeaders = (extra?: HeadersInit): RequestInit => ({
...init,
headers: { ...injected, ...init.headers, ...extra },
})
let res = await fetch(url, withHeaders())
if (res.status !== 401) return res
// 401 → refresh once, then retry with the new Authorization
try {
const { authorization } = await bridge.refreshToken()
res = await fetch(url, withHeaders({ Authorization: authorization }))
} catch {
// refresh failed (terminal auth failure or no host) — return the 401
}
return res
}Bootstrapping the embed session cookie
The /embed canvas can't ride Authorization on every request: it loads widget
bundles via dynamic import(), and the browser's module loader has no headers
API. So the embed bootstraps an HttpOnly embed_bearer cookie (scoped
Path=/api/) that the WebView attaches to every same-origin request,
import() included. There are two ways to get that cookie, tried in order:
- Native mint (preferred).
bridge.establishSession()— the host mints the cookie itself and the bearer never enters page JS. Resolvestrueon success. - Header bootstrap (fallback). When
establishSessionresolvesfalse(an older host that doesn't support it), pullAuthorizationfrombridge.getHeaders()andPOSTit to/api/auth/embed-session, which sets the same cookie. - Legacy
?token=(last resort). Only when the host also lacksheadersfor/embed, exchange a one-time?token=<jwt>from the URL (always scrubbed viahistory.replaceState).
// establishEmbedSession — native mint first, header bootstrap as fallback.
async function establishEmbedSession(bridge: WebViewBridge): Promise<boolean> {
if (await bridge.establishSession()) return true // bearer never touched JS
return bootstrapFromHeaders(bridge) // getHeaders() → POST /api/auth/embed-session
}On a 401, re-establish with the same preference, forcing a fresh token:
// reestablishEmbedSession — native re-mint with a forced refresh, then fallback.
async function reestablishEmbedSession(bridge: WebViewBridge): Promise<boolean> {
if (await bridge.establishSession({ refresh: true })) return true
return refreshFromHeaders(bridge) // refreshToken() → POST /api/auth/embed-session
}Every step tolerates failure: if no path yields the cookie, the boot proceeds
and the downstream batches fetch surfaces the degraded "sign in required" state
rather than blocking. The native-mint path is the security win — see the
Security model and the
establishSession contract.
Rules that matter
- Merge order. Sources merge with later ones winning: injected headers first,
then the caller's explicit headers, then any override. So a caller's explicit
header wins over the injected one on the initial request. On the
post-
401retry the freshly refreshedAuthorizationis applied as the override and takes precedence — it must, or the retry would replay the stale token. - Retry once, not in a loop. A second
401after a fresh token is a real authorization failure, not a stale token. Surface it; do not loop. - Concurrent
401s are fine. The host coalesces concurrentrefreshTokencalls into one real refresh, so many in-flight requests hitting401at once will not trigger a storm. - Non-replayable requests are not retried.
bridge.fetchskips the retry and returns the401wheninputis aRequestobject orinit.bodyis aReadableStream— both are consumed by the first send and cannot be re-issued. The manual form above has the same constraint: only retry when the body can be re-sent (pass a URL string with a string /Blob/ArrayBufferbody, or clone before the first send). Authorizationmay be absent.getHeaderscan return{}(logged out, untrusted domain). Code that requires auth should handle the resulting401, not assume the header was attached.
When the bridge is absent
In a desktop browser getHeaders() resolves to {} and refreshToken()
rejects, so fetchWithAuth reduces to a plain fetch with no injected headers
and no retry. That is the correct local-development behaviour: point your dev
build at an endpoint that does not require the native session, or sign in through
the normal web flow.