Today WebView Bridge
Web SDK

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 answers 401,
  • degrades to a plain fetch outside 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
}

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:

  1. Native mint (preferred). bridge.establishSession() — the host mints the cookie itself and the bearer never enters page JS. Resolves true on success.
  2. Header bootstrap (fallback). When establishSession resolves false (an older host that doesn't support it), pull Authorization from bridge.getHeaders() and POST it to /api/auth/embed-session, which sets the same cookie.
  3. Legacy ?token= (last resort). Only when the host also lacks headers for /embed, exchange a one-time ?token=<jwt> from the URL (always scrubbed via history.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-401 retry the freshly refreshed Authorization is 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 401 after a fresh token is a real authorization failure, not a stale token. Surface it; do not loop.
  • Concurrent 401s are fine. The host coalesces concurrent refreshToken calls into one real refresh, so many in-flight requests hitting 401 at once will not trigger a storm.
  • Non-replayable requests are not retried. bridge.fetch skips the retry and returns the 401 when input is a Request object or init.body is a ReadableStream — 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 / ArrayBuffer body, or clone before the first send).
  • Authorization may be absent. getHeaders can return {} (logged out, untrusted domain). Code that requires auth should handle the resulting 401, 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.

On this page