# Web SDK authentication

> Keep your Runbear API key off the browser with session passes your server mints, or relay the widget through your own proxy. Covers the session endpoint, renewal, storage and declining a session.

Source: https://docs.runbear.io/api/web-sdk/authentication

Last updated: 2026-09-30

A Runbear API key is an **organization-level credential**: anyone who can read it can call the API
as your organization. The widget runs in the browser, so the key must never be shipped in public
browser JavaScript. There are three modes; which one fits depends on where you want the credential
to live and whether your server needs to see every message.

| Mode      | Where the credential lives                                 | Who relays the chat                                    | Use it when                                                                                      |
| --------- | ---------------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------ |
| `session` | A short-lived, thread-bound session pass your server mints | Nobody. The browser talks to `api.runbear.io` directly | You want your server out of the per-message path                                                 |
| `proxy`   | Your server; the browser sends no `Authorization` header   | Your server relays every request                       | You must inspect, log or redact every turn, or you need file attachments or suggested follow-ups |
| `direct`  | The organization API key, in the browser                   | Nobody                                                 | Local prototyping and fully internal tools only                                                  |

## Session mode

Your backend mints a short-lived **session pass** for each visitor and hands it to the widget. The
browser then talks to `api.runbear.io` **directly**, so your server is out of the per-message path.
The API key never leaves your server, and a stolen pass is worth one conversation for a few
minutes. Session mode needs `@runbear-io/react` 0.2.0 or newer.

```
Browser (SDK, holds a pass) ──────────────▶ api.runbear.io   (every message)
        │
        ├── at session start ──▶ your backend ──▶ POST /v1/sessions          (API key)
        └── on each renewal  ──▶ your backend ──▶ POST /v1/sessions/refresh  (API key)
```

What session mode gives you:

- Your server is out of the **per-message** path: it is called once to mint the session, then again
  on each renewal, instead of on every message. It also holds **zero** streaming connections, where
  a proxy holds one open stream for every in-flight message.
- One less network hop on every message.
- Each visitor is isolated to their own conversation with one agent. A proxy authenticates every
  visitor with the same API key and can't express that.

A session pass is bound at mint time to **one agent and one thread**. It can't be widened, and it
can't mint another pass.

### What you need

- An API key with the **`chat`** capability whose agent allowlist, if it has one, includes the
  agent. The same key renews sessions, and a renewal is refused once the allowlist no longer covers
  the session's agent. See [Authentication and API keys](/api/api-keys.md).
- A server endpoint that authenticates your visitor and mints or renews the session:
  [Your session endpoint](#your-session-endpoint).
- The widget configured with `auth: { mode: "session", fetchSessionToken }`.

```ts
import Runbear, { SessionDeclinedError } from "@runbear-io/react"

const client = new Runbear({
  assistantId: "…",
  auth: {
    mode: "session",
    fetchSessionToken: async ({ resumeToken, threadId, signal }) => {
      const res = await fetch("/api/runbear/session", {
        method: "POST",
        headers: { "content-type": "application/json" },
        credentials: "include",
        body: JSON.stringify({ resumeToken, threadId }),
        signal,
      })
      if (res.status === 401 || res.status === 403) {
        // Your endpoint refused this visitor: end the session now.
        throw new SessionDeclinedError(`session refused: ${res.status}`)
      }
      // Anything else is treated as transient and retried with backoff.
      if (!res.ok) throw new Error(`session endpoint failed: ${res.status}`)
      return await res.json()
    },
    storage: "session", // the default
  },
})
```

### Session options

| Option                   | Type                                                           | Default           | What it does                                                                                                                           |
| ------------------------ | -------------------------------------------------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `auth.mode`              | `"session"`                                                    |                   | Selects session mode                                                                                                                   |
| `auth.fetchSessionToken` | `(args: FetchSessionTokenArgs) => Promise<SessionCredentials>` | Required          | Calls your endpoint to mint or renew a session. It must be a function, not a URL: a pass expires and is fetched again mid-conversation |
| `auth.storage`           | `"session"`, `"local"` or `"memory"`                           | `"session"`       | How long the widget remembers the visitor's conversation. See [Where the session is stored](#where-the-session-is-stored)              |
| `auth.sessionKey`        | string                                                         | The `assistantId` | Names this widget's storage entry. Set a distinct value when two widgets on one site use the same agent                                |

### What fetchSessionToken receives

The SDK calls `fetchSessionToken` with one argument, `FetchSessionTokenArgs`:

| Field         | Type             | When it is present                                                                                                                                 |
| ------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `resumeToken` | string, optional | The SDK is **continuing** a session: renewing a pass mid-conversation, or resuming a stored session after a reload. Take your refresh branch       |
| `assistantId` | string           | Always: the agent the widget was created with                                                                                                      |
| `threadId`    | string, optional | The host asked for a specific conversation, with `createChat(threadId)` or `startWithThread(threadId)`. Forward it as `thread_id` on the mint call |
| `signal`      | `AbortSignal`    | Always. It aborts when the widget is destroyed mid-fetch; pass it to `fetch`                                                                       |

`resumeToken` is **absent**, with the key omitted rather than set to `undefined`, for a genuinely new
conversation: a first visit with nothing stored, `reset()`, `startWithMessage()`, a thread-bound mint
(`createChat(threadId)` or `startWithThread()`), and a stored session that belongs to a different
agent. So `"resumeToken" in args` is a safe test.

## Your session endpoint

This is the one piece of code you must write, and the only place your visitors are authenticated.

> **Authenticate the caller**
>
> Runbear can't authenticate your visitor for you: the API key identifies **you**, not them. An
> unauthenticated endpoint here hands anyone on the internet a session on your account.

```ts
import type { SessionCredentials } from "@runbear-io/react"

const RUNBEAR_API = "https://api.runbear.io"

/** The nested shape Runbear returns. Mirrors the `SessionCredentials` OpenAPI component. */
interface RunbearSessionResponse {
  session: { id: string; assistant_id: string; thread_id: string; expiresAt: string }
  pass: { token: string; expiresAt: string; expiresInSeconds: number }
  resumeToken?: { token: string; expiresAt: string; expiresInSeconds: number }
}

// POST /api/runbear/session — YOUR server, YOUR auth
export async function POST(req: Request): Promise<Response> {
  // 1. REQUIRED: authenticate the caller with your own session / cookie / JWT.
  const user = await getSessionUser(req)
  if (!user) return Response.json({ error: "unauthorized" }, { status: 401 })

  const body: { resumeToken?: string; threadId?: string } = await req.json().catch(() => ({}))

  // 2. Mint (no resumeToken) or refresh (resumeToken present). Two routes, never one.
  // `endUser` binds the resume token to THIS visitor. Send the same value on
  // every call for that visitor. Use your stable user id — never an email.
  const callRunbear = (path: string, payload: object): Promise<Response> =>
    fetch(new URL(path, RUNBEAR_API), {
      method: "POST",
      headers: {
        "content-type": "application/json",
        // Server-only. NEVER return this value to the browser.
        authorization: `Bearer ${process.env.RUNBEAR_API_KEY}`,
      },
      body: JSON.stringify(payload),
    })
  const mint = (): Promise<Response> =>
    callRunbear("/v1/sessions", {
      assistant_id: process.env.RUNBEAR_ASSISTANT_ID,
      endUser: user.id,
      ...(body.threadId ? { thread_id: body.threadId } : {}),
    })

  const isRefresh = typeof body.resumeToken === "string" && body.resumeToken.length > 0
  let res = isRefresh
    ? await callRunbear("/v1/sessions/refresh", { resumeToken: body.resumeToken, endUser: user.id })
    : await mint()

  if (isRefresh && res.status === 400) {
    // `resume_token_expired` is the normal end of a session; `resume_token_invalid`
    // means the token can't be used. For both, mint a fresh session WITHOUT the
    // resume token and return it. The widget adopts the new session and its
    // resume token, emitting `sessionChanged` if it was already showing a
    // conversation. Returning an error here instead would NOT clear the stored
    // token: the widget would retry, then fail with the old token still stored.
    const { code } = await res.clone().json().catch(() => ({ code: undefined }))
    if (code === "resume_token_expired" || code === "resume_token_invalid") {
      res = await mint()
    }
  }

  if (!res.ok) {
    return Response.json({ error: "session_unavailable" }, { status: 502 })
  }

  const upstream: RunbearSessionResponse = await res.json()

  // 3. THE MAPPING. Runbear's response is nested; the SDK's credential is flat.
  const credentials: SessionCredentials = {
    pass: upstream.pass.token,
    threadId: upstream.session.thread_id,
    sessionId: upstream.session.id,
    expiresIn: upstream.pass.expiresInSeconds,
    ...(upstream.resumeToken ? { resumeToken: upstream.resumeToken.token } : {}),
  }

  return Response.json(credentials)
}
```

> **Do not return Runbear's response body unchanged**
>
> `return new Response(await res.text())` is the most common mistake. Runbear's response is nested and
> the SDK's credential is flat, so every field the widget reads would be `undefined` and the widget
> fails with `sessionFailed { reason: "misconfigured" }`. Perform the mapping above.

### SessionCredentials

What `fetchSessionToken` must resolve to:

| Field         | Type             | From Runbear's response | Notes                                                                                                                                                                                                                           |
| ------------- | ---------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pass`        | string           | `pass.token`            | Required. The browser credential, sent as `Authorization: Bearer`                                                                                                                                                               |
| `threadId`    | string           | `session.thread_id`     | Required. The one thread this pass may read and run                                                                                                                                                                             |
| `sessionId`   | string           | `session.id`            | Required. Log it: it is the join key across the session's requests                                                                                                                                                              |
| `expiresIn`   | number           | `pass.expiresInSeconds` | Required. The pass's remaining life in **seconds**, measured by the server. Runbear issues 900. It must be at least 10, or the SDK treats the response as misconfigured; sending milliseconds or a timestamp is the usual cause |
| `resumeToken` | string, optional | `resumeToken.token`     | Present on mint, absent on every refresh. When it is absent the SDK keeps the one it has                                                                                                                                        |

`pass.expiresAt`, `resumeToken.expiresAt`, `resumeToken.expiresInSeconds`, `session.assistant_id`
and `session.expiresAt` are server-side bookkeeping; don't forward them. Expiry instants are
deliberately not sent to the browser: comparing them to a browser clock is the failure `expiresIn`
exists to avoid.

**Two credentials, two jobs.**

| Credential    | Current default lifetime                             | What it can do                                         | Where it lives                    |
| ------------- | ---------------------------------------------------- | ------------------------------------------------------ | --------------------------------- |
| `pass`        | 15 minutes                                           | Read and run exactly one thread with exactly one agent | The browser, in memory only       |
| `resumeToken` | 24 hours when you send `endUser`; 2 hours without it | Obtain a fresh pass. **It can never chat.**            | Your server, or the SDK's storage |

The resume token's expiry is the session's absolute ceiling. Refreshing issues a new pass but never
moves that instant; when it passes, mint a new session. Treat both lifetimes as current defaults
read from the response (`expiresInSeconds`), not as guarantees, and never hardcode them.

**Other things this endpoint owns:**

- **Never return the API key** in this response, in any field, for any reason.
- **`endUser` is your stable user id**, never an email address. Runbear salts and hashes it
  server-side and binds the resume token to it; every later refresh must present the
  byte-identical value, or the refresh is rejected. Omit it only for genuinely anonymous widgets:
  the resume token is then a pure bearer credential and is issued with a materially shorter lifetime
  (2 hours instead of 24).
- **Forward `threadId` only when the SDK sends it.** If your endpoint always adds a thread id it
  saved earlier, `reset()` brings back the old conversation instead of starting a new one. The
  thread must belong to your organization and to the same agent, or the mint call returns `404`.

## Declining a session

**Your endpoint is the kill switch.** To end a visitor's session, have your endpoint refuse it (the
samples above answer `401` or `403`) and have `fetchSessionToken` throw `SessionDeclinedError`,
exported from `@runbear-io/react`. The SDK then ends the session immediately, with one call to your
endpoint and no retries: it clears the stored credentials and emits
`sessionFailed { reason: "declined" }`. The visitor's current pass expires within minutes and can't
be renewed.

The SDK also recognizes a `SessionDeclinedError` wrapped as another error's `cause`, such as
`new Error("mint failed", { cause: declined })`, and one thrown by a second copy of the package in
your bundle. `isSessionDeclined(error)` is exported if you need to test for it yourself.

**A plain error is transient, not a kill switch.** Any other error thrown from `fetchSessionToken`,
including a network failure, is retried: the SDK makes 5 attempts in total, waiting about 0.5, 1, 2
and 4 seconds (±20%) between them. What happens next depends on the version:

- **0.5.0 and later (not yet released):** if those attempts were resuming a stored session with its resume token, the
  SDK drops the stored token and makes one more round of attempts without it, which reaches your
  mint branch. Only if that round fails too does it emit `sessionFailed { reason: "network" }`.
- **Any other failed round, and every version up to 0.4.0:** the SDK emits
  `sessionFailed { reason: "network" }` and **keeps** the stored resume token, so the conversation
  can be resumed later with `retrySession()`. On 0.4.0 and earlier, an endpoint that answers an
  expired resume token with an error therefore fails every page load, until your code calls
  `reset()`. The sample above avoids that on every version by minting a fresh session instead.

A response the SDK can't use as a credential, such as a missing field or an `expiresIn` under 10
seconds, throws `SessionCredentialError` internally and ends the session with
`reason: "misconfigured"` after one call. See [Errors](/api/web-sdk/errors.md#session-failures) for how to recover
from each reason.

## Renewal

The SDK renews the pass for you by calling `fetchSessionToken` with the stored resume token:

- **Ahead of expiry.** It renews 60 seconds before the pass expires (halfway through, for a pass that
  lives under 2 minutes). Each successful renewal emits `sessionRenewed`.
- **Not in a background tab.** A renewal that falls due while the tab is hidden waits until the tab
  is visible again.
- **After a `401`.** A `401` with `code: "pass_expired"`, or with no `code`, renews the pass once.
  The failed request is replayed only if it was a read (`GET`, such as loading the history). A
  message send that hits the `401` fails with an error, and the visitor's next send succeeds with
  the fresh pass. Renewing ahead of expiry makes this rare.
- **One renewal at a time.** Concurrent requests share one in-flight call to your endpoint.

> **Sizing your session endpoint**
>
> A pass is short-lived, so **a long conversation renews repeatedly**. With the current 15-minute
> pass the SDK renews a little under once a quarter-hour for as long as the visitor keeps the widget
> open, and every renewal goes through your endpoint. Size it for *one mint per visitor plus one
> renewal per quarter-hour of open widget time*. That is still far below proxy mode, which sees every
> message and holds a stream open for every reply.

## Where the session is stored

The widget remembers which conversation a visitor is in, so a reload doesn't start a new one.
`auth.storage` decides how long that memory lasts:

| Value                 | Backed by        | Lasts                                                               |
| --------------------- | ---------------- | ------------------------------------------------------------------- |
| `"session"` (default) | `sessionStorage` | Until the tab closes; the next visit starts a new conversation      |
| `"local"`             | `localStorage`   | On the device, so the conversation is still there on the next visit |
| `"memory"`            | Nothing          | Until the page reloads                                              |

The entry is stored under the key `runbear.session.v1.<sessionKey>`, where `sessionKey` defaults to
the agent's `assistantId`. If another live widget on the page already uses that key, or the browser
blocks storage, the widget falls back to memory and logs a console warning. A stored session that
belongs to a different agent is discarded, and the widget starts a new conversation.

> **Persisting the session also persists a credential**
>
> What is stored is the thread and session ids plus the session's **resume token**, the credential
> that obtains a fresh pass and re-enters that conversation. The pass itself is never written to
> storage. Under `"session"` the resume token dies with the tab. Under `"local"` it stays on the
> device and is readable by any script on your origin, so on a shared, kiosk or library browser the
> next visitor can land in the previous visitor's conversation. Choose `"local"` when the widget sits
> behind your own per-user login on a personal device; don't reach for it as a default.

To reopen a specific saved conversation rather than the stored one, see
[Saving and reopening a conversation](/api/web-sdk/chat-api.md#saving-and-reopening-a-conversation).

## What a session pass can do

A pass is accepted on exactly three endpoints, and only for the thread and agent it was minted for:

| Endpoint                                  | Purpose                               |
| ----------------------------------------- | ------------------------------------- |
| `GET /v1/threads/{threadId}/messages`     | Read this conversation's history      |
| `POST /v1/threads/{threadId}/runs`        | Send a message and wait for the reply |
| `POST /v1/threads/{threadId}/runs/stream` | Send a message and stream the reply   |

Everything else returns **`403`**, including creating or listing threads, listing or reading agents
and their instructions, traces and analytics, knowledge and tool configuration, API-key management,
file upload, suggested follow-ups, and minting or refreshing another session. A request naming a
**different agent** is also a `403`. A request naming a **different thread**, on one of the three
routes above, is a **`404`**, worded and shaped exactly like a thread that doesn't exist: Runbear
deliberately doesn't confirm whether someone else's thread id is real.

**Not supported in session mode:**

- **File attachments.** The upload endpoint is denied to a session pass, so the widget hides its
  attachment button. Use proxy mode if attachments matter to you.
- **Suggested follow-up questions.** The suggestions endpoint isn't bound to a conversation, so a
  session pass can't use it. `config.suggestions.enabled` is a no-op in session mode, and the SDK
  warns once in the console.
- **Reading agent metadata.** The widget's displayed name and avatar come from
  `config.assistant.name` and `config.assistant.avatarUrl`, not from an API read.
- **Listing a visitor's past conversations.** A pass sees one thread. Keep your own list of thread
  ids per user if you need a history picker, and mint against the chosen one.

A conversation that is created by a mint call and never receives a message is removed after 7 days.
A stored thread id for such an empty conversation stops resolving; mint a fresh session in that case.

### Session limits

Each session has its own limits on top of the organization's:

| Limit                       | Counted per                 | What happens when it is reached                                                                                   |
| --------------------------- | --------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| 60 requests per 60 seconds  | Session                     | `429` with `code: "rate_limited"`; the next window succeeds                                                       |
| 100 turns                   | Session, for its whole life | `429` with `code: "session_turn_cap_exhausted"`. Terminal: mint a new session                                     |
| 240 renewals                | Session, for its whole life | `429` with `code: "session_refresh_cap_exhausted"` from `POST /v1/sessions/refresh`. Terminal: mint a new session |
| 600 mints per 60 seconds    | Organization and agent      | `POST /v1/sessions` answers `429`                                                                                 |
| 600 renewals per 60 seconds | Organization                | `POST /v1/sessions/refresh` answers `429`                                                                         |

Every run made with a session pass also counts against the organization's chat limit of 600
requests per 60 seconds per agent, which is **shared by every visitor of the widget**. Contact
[support@runbear.io](mailto:support@runbear.io) before a high-traffic launch. See
[Errors and limits](/api/errors-and-limits.md#rate-limits) for the full table and the rate-limit headers.

## Proxy mode

Point the widget at an endpoint on your own backend. The SDK sends every request there with no
`Authorization` header; your server forwards it to `https://api.runbear.io` and attaches the API
key server-side.

```ts
const client = new Runbear({
  assistantId: "…",
  auth: { mode: "proxy", baseUrl: "https://app.example.com/api/runbear" },
})
```

The SDK appends the API path to `baseUrl`, so your relay receives, for example,
`https://app.example.com/api/runbear/v1/threads`. Forward these routes:

| Route                                     | Notes                                                                                                                                 |
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `POST /v1/threads`                        | Creates the conversation when the widget mounts, and on `reset()`                                                                     |
| `GET /v1/threads/{threadId}/messages`     | Loads a conversation's history                                                                                                        |
| `POST /v1/threads/{threadId}/runs/stream` | Sends a message. The reply is NDJSON: **stream it through, don't buffer it**, or the visitor sees nothing until the reply is complete |
| `POST /v1/chat/suggestions`               | Only when `config.suggestions.enabled` is `true`                                                                                      |
| `POST /v1/files/upload`                   | Attachments. A `multipart/form-data` body, up to 50 MB; forward it unchanged                                                          |

Proxy mode is fully supported. It is the right choice when you want every message to pass through
your own logging, redaction or compliance layer, when you inject server-side context per request, or
when you need the features session mode doesn't cover.

## Direct mode (development only)

Pass an API key and the SDK calls `api.runbear.io` straight from the browser.

```ts
const client = new Runbear({
  assistantId: "…",
  auth: { mode: "direct", apiKey: "<your Runbear API key>" }, // exposed in the browser
})
```

`auth: { mode: "direct", apiKey, baseUrl }` sends the key **to** a custom origin, for example a
gateway that expects it. Unlike proxy mode, the credential is still attached.

> **Never ship an API key to a browser**
>
> Direct mode is for local prototyping and fully internal tools whose bundle isn't public. For
> anything customer-facing, use session mode or proxy mode.

## Legacy options

Before 0.2.0 the mode was chosen with top-level options. They still work but are deprecated:

- **`baseUrl`** is proxy mode and sends no credential at all, even when `apiKey` is also set.
- **`apiKey` without `baseUrl`** is direct mode.
- **`auth` wins.** When `auth` is present together with a top-level `apiKey` or `baseUrl`, the SDK
  ignores the legacy options and logs a console warning.

The constructor throws in two cases:

- A top-level `apiKey` together with `auth: { mode: "session" }`, because that combination would
  ship an API key to the browser.
- No `auth`, no `apiKey` and no `baseUrl`: the widget would have no way to authenticate.

Setting `config.suggestions.enabled` in session mode doesn't throw; it logs a warning and has no
effect.

## Migrating from proxy mode

The whole migration is: delete the catch-all forwarder, add one route, change the client options.

**Before: your server forwards every message.**

```ts
// /api/runbear/[...path]  — one request per message, one open stream per reply
const upstream = new URL(path, "https://api.runbear.io")
const res = await fetch(upstream, {
  method: req.method,
  headers: { ...forwardedHeaders, authorization: `Bearer ${process.env.RUNBEAR_API_KEY}` },
  body: req.body,
})
```

**After: your server mints once per visitor.** Delete the route above and add the single
`/api/runbear/session` route from [Your session endpoint](#your-session-endpoint). In the browser,
replace `baseUrl` (or `auth: { mode: "proxy", … }`) with the session-mode options from
[What you need](#what-you-need).

**Checklist**

1. Upgrade to `@runbear-io/react` 0.2.0 or newer.
2. Add the session route; keep your existing visitor authentication in front of it.
3. Move `RUNBEAR_ASSISTANT_ID` next to `RUNBEAR_API_KEY` in your server environment. Neither belongs
   in browser code.
4. Switch the client to `auth: { mode: "session", … }` and remove any top-level `apiKey`.
5. To reopen a saved conversation, send the stored thread id as `threadId` from the browser; your
   endpoint forwards it as `thread_id` on the mint call.
6. Delete the catch-all proxy route once traffic has moved.

Migration is reversible at any time: proxy mode is fully supported, so switching the client options
back is the rollback. What you give up is listed under
[Not supported in session mode](#what-a-session-pass-can-do).

## Related

- [Errors](/api/web-sdk/errors.md): session failures, renewal statuses and recovery
- [Chat API](/api/web-sdk/chat-api.md): session events and `retrySession()`
- [Authentication and API keys](/api/api-keys.md): creating the key your endpoint uses
- [OpenAPI reference](https://api.runbear.io/v1/docs): exact schemas for `POST /v1/sessions`,
  `POST /v1/sessions/refresh` and the three session-authorized endpoints
