---
title: "@octabits-io/session"
summary: "The browser side of sign-in, framework-free: the session and the granted organisations as observable stores, a route guard with an injected policy hook, and a dev/E2E bypass that refuses production builds — all with no vendor in the root."
install: "pnpm add @octabits-io/session"
availability: "Available from November 2026"
version: "0.8.0"
license: "MIT"
related: []
canonical_url: "https://octabits.io/platform/session"
---

# `@octabits-io/session`

The browser side of sign-in, framework-free. Everything a Vue or React app needs
between "the user opened the page" and "every request carries a valid bearer": the
session and the granted organisations as observable stores, a route guard, and a
dev/E2E bypass that refuses production builds. The **root has no peer at all** — the
user manager and the profile claims are structural types, so the provider is a
subpath: `./oidc` speaks plain OIDC through `oidc-client-ts`, `./better-auth` puts
[Better Auth](https://www.better-auth.com) behind the same contracts. `./vue` and
`./react` bind the same stores, and never learn which provider is underneath.

```ts
import { createSessionStore, createAuthGuard, createOrgStore } from '@octabits-io/session';
import { createUserManagerFactory, ZITADEL_ORG_PROJECT_SCOPE } from '@octabits-io/session/oidc';
import { createBetterAuthSessionStore } from '@octabits-io/session/better-auth';
import { createAuthSessionCore } from '@octabits-io/session/vue';
import { useSession, useAuthGuard } from '@octabits-io/session/react';
```

| Subpath | Peers | What it is |
|---|---|---|
| `@octabits-io/session` | — | The neutral contract: `createSessionStore` and `defaultAuthUserMapper`, `createOrgStore` and `currentOrganizationOf`, `createAuthGuard`, `seedAuthBypassSession`, and `createStore` (the observable primitive). Imports **nothing** — no vendor, no framework, no `node:*`. |
| `./oidc` | `oidc-client-ts` | The OIDC provider: `createUserManagerFactory`, `attachSessionLifecycleHandlers`, `createLoginRedirector`, `removeStaleOidcKeys`, `isUnrecoverableRenewError`, the two Zitadel scope presets and the Microsoft Entra ID presets. The one subpath that knows the vendor. |
| `./better-auth` | — | The second provider: `createBetterAuthSessionStore` — a `SessionStore` over Better Auth's own client, as the JWT bridge, on a bearer or a cookie transport — plus `createFetchAuthClient`, `organizationsFromSession`, `createLocalTokenStorage`, `decodeJwtPayload`. Sign in by password, mailed code or passkey. `better-auth` is never imported. |
| `./vue` | `vue` | `createAuthSessionCore`, `createOrgStoreCore`: refs and computeds an app spreads into its own store. |
| `./react` | `react` | `useSession`, `useOrg`, `useAuthGuard` over `useSyncExternalStore`. |

## When not to use it

- **A server-side session.** This is the browser: the API's bearer is in the page, and
  every request carries it itself. (`./better-auth`'s cookie transport keeps Better
  Auth's *session* in an `HttpOnly` cookie, but the short-lived JWT the API sees is
  still the page's.) If the requirement is a backend-for-frontend or a confidential
  client, use a server-side OIDC library and hand the SPA a session, not a token.
- **Validating the token.** Nothing here checks a signature or a claim; the API does
  that — `@octabits-io/server/auth`'s JWT validator, and
  `@octabits-io/server/auth/better-auth` for the claim shape `./better-auth` mints
  against. The session store trusts what the provider returned to the browser.
- **Machines.** Authorization code with PKCE (`./oidc`) or an email sign-in
  (`./better-auth`), and only that: no client credentials, no device flow.
  Service-to-service access is an API key or a client-credentials grant on the server.
- **Managing users.** No sign-up, profile, role or organisation administration — the
  org store lists what the user was granted and remembers a selection. Administration
  goes to the provider's own API (Zitadel's management API for Zitadel,
  Better Auth's `organization` plugin for Better Auth).
- **Rendering on the server.** It runs on web-platform globals — `window`,
  `localStorage` — so it belongs in a client plugin or a client component; a
  server-rendered page cannot restore the session before hydration.

## The contract (the root)

- **`createSessionStore({ getUserManager, mapUser })`**: the session as an observable,
  `get()` + `subscribe()`. State: `status` (`unknown` → `anonymous` | `authenticated` |
  `expired`), `user` (your mapped shape), `accessToken`, `claims`, `initialized`,
  `loading`, `error`. Actions: `checkAuth` (restore from storage; an expired access
  token with a refresh token on file is silently renewed first, so closing the browser
  overnight does not end the session at the access-token lifetime), `login(returnUrl)`,
  `handleCallback()` (returns the return URL), `logout()` (with `id_token_hint`),
  `markExpired()`; `refresh`/`signIn`/`signOut` are aliases.
- **`SessionUserManager`, `SessionUser`, `SessionClaims`**: the provider seam, typed
  structurally — six methods and three token fields. `oidc-client-ts`'s `UserManager`
  and `User` satisfy them as they are, which is why the root needs no peer and why
  `./better-auth` can build the same `SessionStore` a different way.
- **`createOrgStore({ fetchOrganizations, getSlug })`**: the orgs the user is granted,
  the selected slug persisted to storage, revocation of a selection whose access was
  lost. `currentOrganizationOf(state, getSlug)` is the pure derivation.
- **`createAuthGuard({ ensureAuthenticated, afterAuthenticated })`**: returns
  `(route) => redirectTarget | undefined`. Public routes pass; unauthenticated users go
  to `/login?redirect=…`; the `afterAuthenticated` hook is where an app's own policy
  lives: org validation, role gates, acceptance gates.
- **`withStepUp(fn, { prompt })`**: the client half of
  `@octabits-io/server/authz`'s `requireFresh`. A route that wants a fresh proof
  answers `403 { key: 'step_up_required', maxAgeSeconds, methods, reason }`;
  `withStepUp` finds that answer in what `fn` returned (a `Result`, an error body)
  or threw, runs the provider's re-authentication — a `StepUpPrompt` from
  `./better-auth` (`createPasskeyStepUp`) or `./oidc` (`createOidcStepUp`) — and
  retries `fn` **exactly once**. A dismissed prompt is one
  `{ ok: false, error: { key: 'step_up_cancelled', challenge } }` (or one thrown
  error of that shape, when `fn` threw its challenge), never a loop; a second
  challenge after the retry is returned as is. `createStepUp({ prompt })` binds one
  prompt and exposes `prompting` and the last `challenge` as an observable for
  `./vue`'s and `./react`'s `useStepUp`; `isStepUpFailure(outcome)` narrows.
- **`seedAuthBypassSession({ bypassSecret, isProductionBuild, … })`**: a fake session
  whose access token is a shared secret the API accepts, for local development and
  browser tests. Refuses unconditionally when `isProductionBuild` is true, which must
  be the build-time flag, so a leaked runtime variable cannot enable it.
  This one seeds an OIDC session; for Better Auth see `createAuthBypassSessionStore`
  under `./better-auth`.

## `./oidc` — the OIDC provider

- **`createUserManagerFactory({ getConfig, scope, … })`**: a lazily-created
  `UserManager` singleton bound to `localStorage`; `getConfig` runs at first use, so
  runtime-injected config (a `window.__APP_CONFIG__`) works.
- **`attachSessionLifecycleHandlers(userManager, { redirectToLogin, notify, onSessionLost })`**:
  classifies `oidc-client-ts` events. A recoverable renew failure notifies; an
  unrecoverable one (`login_required`, `invalid_grant`, …) notifies and redirects;
  expiry and back-channel signout clear the session and redirect. Copy and toasts stay
  in the app.
- **`createLoginRedirector({ getUserManager })`**: the signin redirect carrying the
  current path as state, with a plain `/login?redirect=` fallback when the IdP redirect
  cannot start; a no-op on the auth routes themselves.
- **Zitadel presets**: `ZITADEL_ORG_PROJECT_SCOPE` requests the organisation and
  project-role claims plus a refresh token; `ZITADEL_REFRESH_TOKEN_ALLOWED_SCOPE` is
  the standard-scope subset Zitadel accepts on the refresh grant. A preset, not a
  requirement: any OIDC provider works with `scope: 'openid profile email'`.
- **Microsoft Entra ID presets**: `entraAuthority(directoryId)` is one directory's
  v2.0 issuer (it throws on `common`, `organizations` and `consumers`, whose
  discovery documents carry a template where the issuer should be, so no ID token
  would validate); `entraScope(entraApiScope(apiClientId))` asks for a refresh token
  and the API's own scope — without it, Entra mints the access token for Microsoft
  Graph and the API cannot verify it; `entraUserMapper` keys the user by `oid`,
  because Entra's `sub` differs per application and would not match the API's
  principal. No refresh-scope preset: Entra takes the granted scopes back as they
  are. A single-page application's refresh token lives 24 hours, after which the
  silent renew fails as `invalid_grant` and the lifecycle handlers send the user to
  sign in. The API half is `@octabits-io/server/auth/entra`; the registration is the
  `configure-entra-for-an-app` skill.

  ```ts
  const getUserManager = createUserManagerFactory({
    getConfig: () => ({ issuerUrl: entraAuthority(config.entra.directoryId), clientId: config.entra.spaClientId }),
    scope: entraScope(entraApiScope(config.entra.apiClientId)),
  });
  export const session = createSessionStore({ getUserManager, mapUser: entraUserMapper });
  ```

```ts
// auth.ts, once per app
const getUserManager = createUserManagerFactory({ getConfig: () => config.oidc, scope: 'openid profile email offline_access' });
export const session = createSessionStore({ getUserManager, mapUser: defaultAuthUserMapper });
export const guard = createAuthGuard({
  ensureAuthenticated: async () => { if (!session.get().initialized) await session.checkAuth(); return session.get().status === 'authenticated'; },
});
attachSessionLifecycleHandlers(getUserManager(), {
  redirectToLogin: createLoginRedirector({ getUserManager }),
  onSessionLost: () => session.markExpired(),
});
```

## `./better-auth` — the JWT bridge

Better Auth owns sign-in, sessions and organisations; its `jwt` plugin mints a
short-lived JWT for a session, signed with keys it publishes at `/api/auth/jwks`. The
store holds that JWT where an OIDC access token would be, and the API verifies it by
that key set. The API never asks Better Auth who a caller is.

```ts
import { createAuthClient } from 'better-auth/client';
import { createBetterAuthSessionStore, createLocalTokenStorage, organizationsFromSession } from '@octabits-io/session/better-auth';
import { createAuthGuard, createOrgStore } from '@octabits-io/session';

const sessionToken = createLocalTokenStorage();
const client = createAuthClient({
  baseURL: API_URL,
  fetchOptions: {
    auth: { type: 'Bearer', token: () => sessionToken.get() ?? '' },
    credentials: 'omit', // no cookie in this shape; see "Two tokens"
  },
});
export const session = createBetterAuthSessionStore({ client, sessionToken });
export const orgs = createOrgStore({ fetchOrganizations: () => organizationsFromSession(session), getSlug: (org) => org.slug });
```

`better-auth` is never imported and never a peer. The client is typed by the four
members the store calls (`getSession`, `signIn.email`, `signOut`, `$fetch`) —
`createAuthClient(...)` satisfies it as is, any version fits, and a test hands in a
fake. `scripts/check-boundaries.mjs` holds the subpath to that.

The claim shape both sides agree on — `orgs: { id, slug, name, role }[]` plus `org`,
`email`, `name`, `picture` — is `@octabits-io/server/auth/better-auth`:
`defineOctabitsJwtPayload` for Better Auth's `jwt.definePayload`, `betterAuthClaimMapper`
and `betterAuthJwksUri` for the API's validator. `organizationsFromSession` feeds the
org store from the token, so the org switcher costs no request.

Switching is `session.setActiveOrganization(id)`: the organization plugin's
`set-active` changes the session, and the store mints a JWT that names the new
`org` at once, because the token is what the API reads. A refusal is not a
no-op: Better Auth clears the active organisation when the user is not a member,
and the store mints after a refusal too, so the token names none.

### Two tokens

The store keeps two tokens, and it matters which goes where.

- **The session token** is Better Auth's. The `bearer` plugin hands it back on sign-in
  (`set-auth-token` header; the body carries it too), the store keeps it in
  `localStorage` (or any `TokenStorage` you pass) and your Better Auth client sends it
  on every call — that is the `fetchOptions.auth` line above. It names a server-side
  session that Better Auth can end.
- **The JWT** is what your API sees. The store mints it from the `jwt` plugin's
  `/token` endpoint once signed in, exposes it as `state.accessToken`, and mints a
  fresh one a minute before `exp` (the plugin's default lifetime is 15 minutes). It is
  verified by JWKS, offline; revocation is the lifetime.

Never put the session token on the API's bearer — the API cannot verify it — and never
put the JWT on a Better Auth call.

### The cookie transport

When Better Auth is served same-site with the app (the app's own origin, or a sibling
host under the same registrable domain), the session can stay a cookie the page never
sees:

```ts
import { createBetterAuthSessionStore, createFetchAuthClient } from '@octabits-io/session/better-auth';

export const session = createBetterAuthSessionStore({
  client: createFetchAuthClient({ baseUrl: 'https://app.example.com/api/auth' }),
  transport: 'cookie',
});
```

- **Nothing about the session is in `localStorage`.** Better Auth's session cookie is
  `HttpOnly`; the store asks the server whether it exists (`get-session`) instead of
  reading a token on file. The JWT is minted into memory as on the bearer transport,
  so a page load costs one `get-session` and one `/token`.
- **Ask for the bearer with `ensureAccessToken()`**, not `get().accessToken`: it restores
  the session on first use and mints again near `exp`, which the renew timer cannot do
  while a laptop sleeps. It works the same on the bearer transport.
- **`createFetchAuthClient` is plain `fetch` with `credentials: 'include'`**, so none
  of Better Auth's client reaches the bundle. Its errors keep Better Auth's shape,
  `code` included.
- **Same-site, or the cookie is not sent.** Better Auth's cookie is `SameSite=Lax`, so a
  cross-*site* auth server is the bearer transport's job. A cross-*origin*, same-site
  one works, and must answer with `Access-Control-Allow-Credentials: true` and the
  exact origin.

### Signing in without a password

`requestCode(email)` and `signInWithCode({ email, code })` drive the `emailOTP` plugin;
`signInWithPasskey(ceremony)` drives `@better-auth/passkey`. The WebAuthn ceremony is
injected, so no WebAuthn library ships here:

```ts
import { startAuthentication } from '@simplewebauthn/browser';

await session.signInWithPasskey((optionsJSON) => startAuthentication({ optionsJSON }));
```

A dismissed browser prompt resolves to `{ ok: false, error: { code: 'cancelled' } }`
and leaves no error on the store: it is a choice, and a page can stay silent about it.
All three work on both transports; on the bearer one, each keeps the session token the
endpoint returned.

A passkey is added to the signed-in account with `registerPasskey(ceremony, { name })`:
the plugin's registration options, the injected ceremony, the verification. The session
does not change, and a dismissed prompt is `cancelled` here too.

```ts
import { startRegistration } from '@simplewebauthn/browser';

await session.registerPasskey((optionsJSON) => startRegistration({ optionsJSON }), { name: 'My laptop' });
```

Signing up with a passkey alone: the server creates the account, hands the page an
opaque `context` for it (what `@better-auth/passkey`'s `registration.resolveUser`
reads), and the page registers with `createSession: true` — the verification starts the
session, and the store restores it like any sign-in:

```ts
await session.registerPasskey((optionsJSON) => startRegistration({ optionsJSON }), { context, createSession: true });
```

### Two traps this subpath exists to document

- **`credentials: 'omit'` on the client.** better-fetch includes credentials by
  default; cross-origin that makes every call a preflight the API must answer with
  `Access-Control-Allow-Credentials: true`, and a bearer API has no reason to. Without
  it the sign-in dies as `Failed to fetch` in the browser with nothing in the server
  log (`apps/demo-nuxt`, :3100 → :3101, is the case that found it).
- **A network failure throws out of the client** (`Failed to fetch`) instead of
  resolving `{ error }`. `signInWithEmail` catches that and returns
  `{ ok: false, error: { message } }` so a sign-in page has one shape for both;
  `checkAuth`, `renewAccessToken` and `logout` catch too.

**Step-up.** `createPasskeyStepUp(session, ceremony, { then? })` is the
`StepUpPrompt` for `withStepUp`: it runs `signInWithPasskey`, which opens a new
Better Auth session and mints the JWT again, so the retried call carries a fresh
`auth_time` (`@octabits-io/server/auth/better-auth` writes the session's `createdAt`
as `auth_time`). A dismissed browser prompt is `cancelled`, any other refusal
`failed`. `then` runs after the ceremony and before the retry — where a host whose
API keeps its own session rows (`@octabits-io/server/sessions`) tells it
`reauthenticate(id, { method: 'passkey' })`.

A person with no passkey proves it another way: `createSignInStepUp(session, signIn)`
takes any sign-in the host runs — a dialog that asks for the password again, or for a
mailed code — as `(challenge) => Promise<SignInResult>`, and resolves the same outcomes
(`{ ok: false, error: { code: 'cancelled' } }` from a closed dialog is `cancelled`).
Both prompts **keep the organisation**: a sign-in opens a new Better Auth session, which
acts wherever the server puts a new one, so the prompt reads the active organisation
(`org`) off the token before the sign-in and sets it active again after. A person who
cannot be put back there is a `failed` step-up and the call is not retried — a role
change must never land in another organisation than the one it was asked in.

```ts
import { createStepUp } from '@octabits-io/session';
import { createPasskeyStepUp } from '@octabits-io/session/better-auth';
import { startAuthentication } from '@simplewebauthn/browser';

export const stepUp = createStepUp({ prompt: createPasskeyStepUp(session, (optionsJSON) => startAuthentication({ optionsJSON })) });
const outcome = await stepUp.run(() => api.call(offboardUser, { params: { id } }));
if (isStepUpFailure(outcome)) notice('Not done: you did not confirm with your passkey');
```

**Dev/E2E bypass.** `createAuthBypassSessionStore({ bypassSecret, isProductionBuild, profile? })`
returns a whole `BetterAuthSessionStore` that is always signed in as one identity and
hands out the shared secret as its bearer, or `undefined` without a secret and always in
a production build. There is nothing to seed: a Better Auth session is an HttpOnly
cookie the page cannot forge. Use it in place of the real store:
`createAuthBypassSessionStore({ … }) ?? createBetterAuthSessionStore({ … })`.

**Step-up.** `createOidcStepUp({ getUserManager, mode?, session?, acrValues?, returnUrl? })`
is the `StepUpPrompt` over the provider's own prompt: the authorization request again
with `prompt=login` and the challenge's `max_age`, so the provider makes the person
authenticate now and the new token carries a fresh `auth_time`. `popup` (default) keeps
the page, re-reads the session store (`session.checkAuth()`) and retries; a popup the
person closed is `cancelled`. `redirect` leaves the page with the return URL in the
state and resolves `redirected` — the app re-runs the action after the callback.
`acrValues` asks for a stronger method when the challenge's `methods` need one.

## Vue (Nuxt or plain Vite)

```ts
// stores/auth.ts
export const useAuthStore = defineStore('auth', () =>
  createAuthSessionCore({ getUserManager, mapUser: defaultAuthUserMapper }))

// middleware/auth.global.ts
const guard = createAuthGuard({ ensureAuthenticated, afterAuthenticated: appPolicy })
export default defineNuxtRouteMiddleware(async (to) => {
  const target = await guard(to)
  if (target) return navigateTo(target)
})
```

`createAuthSessionCore` returns computeds over the store (`user`, `status`,
`accessToken`, `isAuthenticated`, `initialized`, `loading`, `error`) plus the actions
and the `store` itself, so a second binding or a lifecycle handler can reach it. It
also takes a store built elsewhere — `createAuthSessionCore({ store })` — the way the
React binding always did, so a second provider is a different store, not a different
binding. `useStepUp(stepUp)` is `prompting` and `challenge` as computeds plus `run`.

## React

```tsx
const verdict = useAuthGuard(guard, { path: location.pathname, fullPath: location.pathname + location.search });
useEffect(() => { if (verdict.status === 'redirect') navigate(verdict.to); }, [verdict]);
if (verdict.status !== 'allowed') return null;

// The token on every request
const { accessToken } = useSession(session);
await fetch('/api/contacts', { headers: accessToken ? { authorization: `Bearer ${accessToken}` } : {} });

// A dangerous action: the passkey prompt on step_up_required, one retry
const { run, prompting } = useStepUp(stepUp);
<button disabled={prompting} onClick={() => run(() => api.call(offboardUser, { params: { id } }))}>Offboard</button>
```

## What is tested

The OIDC factory's `UserManager` settings (the contract an identity provider enforces
in production), the login redirector's return-URL state and fallback, every lifecycle
event, the bypass's production refusal, the session store's renew-then-restore path
and its `expired`-vs-`anonymous` distinction, the org store's revocation and
persistence, both bindings rendered, and the step-up: the challenge found in a
`Result`, a body or a thrown error, the prompt run and the call retried exactly once, a
dismissed prompt surfaced as one failure and never a loop, the passkey and the OIDC
prompts against fakes. The Better Auth store's whole lifecycle
against a fake client: restore from the token on file, anonymous without one, a stale
token dropped, sign-in kept and refused, renewal before expiry and the `expired` state
when it fails, sign-out as `anonymous` rather than `expired`, `login` to the sign-in
page, `handleCallback` honouring `?redirect=`, the org store fed from the token.
`apps/demo-server` runs the real thing (Better Auth over Drizzle on PGlite, sign-in →
`/token` → the API accepting the JWT through the JWKS it serves), and `apps/demo-nuxt`,
`apps/fixture-react` and `apps/fixture-shadcn` sign in through it while Playwright types the
credentials.

## Stability

0.x: patches never break, minors say so in the changelog. The `SessionStatus` values,
the guard's return contract and the Better Auth claim shape (`orgs`, `org`) are part of
the surface. Replaces `@octabits-io/oidc-session` and `@octabits-io/better-auth`, whose
client half is `./better-auth` here and whose server half is
`@octabits-io/server/auth/better-auth`; both old names are deprecated on npm.

## License

MIT