# Introduction (https://session-kit.pages.dev/docs) Session kit is a schema-driven toolkit for defining, validating, and managing the session in a React app whose own backend signs users in and issues access and refresh tokens. You give it the tokens your backend returns, and it manages the session from there. It supports TanStack Router and Start, React Router, Next.js, Expo Router on iOS and Android, and Effect. This page explains why it exists, what it does, and the terms the rest of the docs use. To start using it, see [Installation](https://session-kit.pages.dev/docs/installation). ## Why Session kit Signing in is the easy part. Your backend checks a password and answers with an access token and a refresh token, and for a moment the app is done. Then the user reloads the page. The access token expires halfway through a form. They open a second tab, sign out of one, and switch accounts in the other. The backend goes down for a minute, just as a refresh was due. Every app with its own backend has to get these moments right. It usually takes a few hundred lines spread across an auth context, a fetch wrapper, and the route guards, and one or two cases stay missing until a user finds them. Session kit is that code, written once and tested with the routers and frameworks it supports. ### What it does Session kit handles each of these moments for you: | When… | Session kit… | | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | The page reloads | Restores the saved session and checks the user against your schema again. With `localStorage` and a synchronous schema, before the first paint. | | The access token is about to expire | Refreshes it early, in the background, so requests don't wait. | | A request gets a 401 anyway | Refreshes once, however many requests failed, then replays the safe ones. | | Two tabs need a refresh at the same moment | Lets one tab spend the refresh token and the other reuse what it saved, where the browser supports Web Locks. | | The user signs out or switches account in one tab | Follows in every tab, and remounts guarded pages so nothing of the previous account stays on screen. | | The backend rejects the refresh token | Signs out in every tab and clears storage. | | The backend is down when a refresh is due | Keeps the session, reports the error, and waits for a retry. An outage is not a sign-out. | | A signed-out user opens a private page | Sends them to sign-in, and back to where they were going afterwards. | | The page renders on the server | Reads the session from an encrypted cookie, so the first response is already signed in. | ### What it doesn't do Session kit doesn't sign users in. It has no sign-in form, no OAuth flow, and no user database. It starts where your backend's sign-in response ends. If you need those too, use an auth service or framework such as Auth.js, Better Auth, or Clerk, which manage their own sessions. ## How it works Session kit sits between your backend and your components. Tokens travel on the left, and only state reaches the right: ```text Your backend Session kit Your components ┌───────────────┐ ┌───────────────────┐ ┌──────────────────┐ │ POST /login │ ───▶ │ signIn() │ ───▶ │ useAuth() │ │ POST /refresh │ ◀──▶ │ refresh, on time │ │ route guards │ │ GET /orders │ ◀─── │ authFetch() │ ◀─── │ loaders, queries │ └───────────────┘ └───────────────────┘ └──────────────────┘ issues tokens keeps the session state, never tokens ``` Your backend issues the tokens. Session kit saves them, refreshes them on time, and attaches them to your requests. Your components and route guards read the session's state, and never see a token. Five terms come up throughout: - **Tokens** are what your backend issues. The access token goes with each request and is short-lived. The refresh token, if there is one, is exchanged for a new access token when the old one expires. - **The client**, from `createAuth`, holds the tokens and does the work: saving, restoring, refreshing, and telling other tabs. An app usually has one. - **A session** is who is signed in: `{ sessionId, user }`. It never holds tokens, so it is safe to render and to pass around. - **State** is what components read: `status`, `user`, `sessionId`, and `error`, updated as the session changes. - **Credentials** are the access token and its expiry, given only to code that attaches them to requests, such as `createAuthFetch`. `status` says where the session stands, and checking it narrows `user`: | `status` | Meaning | `user` | | ----------------- | ------------------------------------------------------------------------- | ------ | | `initializing` | The saved session hasn't been restored yet. | `null` | | `authenticated` | Access is usable. | Set | | `refreshing` | A refresh is replacing expired or rejected access. | Set | | `locked` | With `locking`, a session is saved, but its tokens wait for `unlock()`. | Set | | `unauthenticated` | No session; sign-in is needed. | `null` | | `unavailable` | Restoring failed, or a failed refresh left no usable access; see `error`. | `null` | The session is kept in one of two places, depending on where your app renders: | | In the browser or on the device | On the server | | ----------------- | -------------------------------------------------------------------------------- | ---------------------------------------------------------- | | For | Single-page apps and Expo apps | TanStack Start, Next.js, React Router with SSR | | Client | `createAuth` | `createServerAuth`, with `createMirrorAuth` in the browser | | Saved in | `localStorage`, `sessionStorage`, the iOS keychain, or Android encrypted storage | An encrypted `httpOnly` cookie | | The refresh token | Stays in that storage | Never reaches the browser | Hooks, guards, and tab sync work the same in both. ## Next steps - [Installation](https://session-kit.pages.dev/docs/installation): install the package and find the guide for your setup. - [Getting started](https://session-kit.pages.dev/docs/getting-started): build a small app that signs in, stays signed in, and calls an API. - [Server rendering](https://session-kit.pages.dev/docs/server-rendering): keep the session on the server with TanStack Start, Next.js, or React Router. # Installation (https://session-kit.pages.dev/docs/installation) Install Session kit, a schema library, and the packages your setup needs, then follow the guide for that setup. To install the package, run the following command: ```npm npm install @monarcode/session-kit ``` Alpha releases are published to the `latest` distribution tag and may change the public API, so pin an exact version and read the [release notes](https://github.com/monarcode/session-kit/blob/main/CHANGELOG.md) before upgrading. Session kit checks each user against a schema from any [Standard Schema](https://standardschema.dev) library. The examples use Zod. To install it, run the following command: ```npm npm install zod ``` Then install React, and the router or framework your setup uses. [Compatibility](https://session-kit.pages.dev/docs/reference/compatibility) lists every optional peer dependency and the versions it supports. ## Pick your setup Find your setup in the following table. Each one has an import path and a guide. All but the first also have a runnable app in [`examples/`](https://github.com/monarcode/session-kit/tree/main/examples) that signs in against DummyJSON's public test API: | Setup | Import | Guide | Example app | | ------------------------------------------ | -------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | React without a router | `@monarcode/session-kit/react` | [Getting started](https://session-kit.pages.dev/docs/getting-started) | None | | TanStack Router | `@monarcode/session-kit/tanstack-router` | [TanStack Router](https://session-kit.pages.dev/docs/setups/tanstack-router) | [`tanstack-router`](https://github.com/monarcode/session-kit/tree/main/examples/tanstack-router) | | TanStack Start, SPA mode | `@monarcode/session-kit/tanstack-router` | [TanStack Router](https://session-kit.pages.dev/docs/setups/tanstack-router#tanstack-start-in-spa-mode) | [`tanstack-start-spa`](https://github.com/monarcode/session-kit/tree/main/examples/tanstack-start-spa), [`tanstack-start-spa-effetch`](https://github.com/monarcode/session-kit/tree/main/examples/tanstack-start-spa-effetch) | | TanStack Start with server rendering | `@monarcode/session-kit/tanstack-start` | [TanStack Start](https://session-kit.pages.dev/docs/setups/tanstack-start) | [`tanstack-start-ssr`](https://github.com/monarcode/session-kit/tree/main/examples/tanstack-start-ssr) | | React Router, declarative mode | `@monarcode/session-kit/react-router` | [React Router](https://session-kit.pages.dev/docs/setups/react-router#declarative-mode) | [`react-router-declarative`](https://github.com/monarcode/session-kit/tree/main/examples/react-router-declarative) | | React Router, data mode | `@monarcode/session-kit/react-router` | [React Router](https://session-kit.pages.dev/docs/setups/react-router#data-mode) | [`react-router-data`](https://github.com/monarcode/session-kit/tree/main/examples/react-router-data) | | React Router, framework mode, `ssr: false` | `@monarcode/session-kit/react-router` | [React Router](https://session-kit.pages.dev/docs/setups/react-router#framework-mode-with-ssr-false) | [`react-router-framework-spa`](https://github.com/monarcode/session-kit/tree/main/examples/react-router-framework-spa) | | React Router, framework mode with SSR | `@monarcode/session-kit/react-router/server` | [React Router with SSR](https://session-kit.pages.dev/docs/setups/react-router-ssr) | [`react-router-framework-ssr`](https://github.com/monarcode/session-kit/tree/main/examples/react-router-framework-ssr) | | Next.js 16, App Router | `@monarcode/session-kit/next` | [Next.js](https://session-kit.pages.dev/docs/setups/next) | [`next-app`](https://github.com/monarcode/session-kit/tree/main/examples/next-app) | | Expo Router on iOS and Android, SDK 53+ | `@monarcode/session-kit/expo` | [Expo Router](https://session-kit.pages.dev/docs/setups/expo) | [`expo-router`](https://github.com/monarcode/session-kit/tree/main/examples/expo-router) | Two guides apply to more than one setup: - [Server sessions](https://session-kit.pages.dev/docs/server-rendering/server-sessions) covers what the three server-rendered setups share. - [Effect and effetch](https://session-kit.pages.dev/docs/effect) works with any setup. ## Docs for coding assistants These docs are also plain Markdown, for AI coding assistants. Point yours at [`/llms.txt`](https://session-kit.pages.dev/llms.txt), which links every page in reading order, or [`/llms-full.txt`](https://session-kit.pages.dev/llms-full.txt), which holds them all in one file. Add `.md` to a page's URL for that page alone. ## Next steps - For a setup that keeps the session in the browser or on the device, start with [Getting started](https://session-kit.pages.dev/docs/getting-started). The web router guides build on its app. The Expo guide starts from scratch. - For a server-rendered setup, start with [Server rendering](https://session-kit.pages.dev/docs/server-rendering). # Getting started (https://session-kit.pages.dev/docs/getting-started) In this guide, you build a small React app that signs in, stays signed in across reloads and tabs, calls an API, and signs out. The web router guides build on this app. Server-rendered apps create their client differently, so start from [Server rendering](https://session-kit.pages.dev/docs/server-rendering) instead. The code signs in against [DummyJSON's auth API](https://dummyjson.com/docs/auth), a public test backend, so it runs as written. ## Before you begin In a React app, install Session kit and Zod, as [Installation](https://session-kit.pages.dev/docs/installation) shows. ## 1. Talk to your backend Session kit never calls your backend for you, so start with the two requests you would write anyway: one that signs in, and one that exchanges a refresh token for new tokens. Nothing in this file imports Session kit: ```ts title="src/backend.ts" import { z } from "zod"; export const API = "https://dummyjson.com"; export const userSchema = z.object({ id: z.number(), username: z.string(), email: z.string(), firstName: z.string(), }); const tokensSchema = z.object({ accessToken: z.string(), refreshToken: z.string(), }); const post = (path: string, body: unknown, init?: RequestInit) => fetch(API + path, { ...init, method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); /** Checks the password, and resolves the tokens and the user. */ export async function login(username: string, password: string) { const response = await post("/auth/login", { username, password }); if (!response.ok) throw new Error(`Sign-in failed (${response.status})`); // DummyJSON returns the user's fields beside the tokens. const body: unknown = await response.json(); return { ...tokensSchema.parse(body), user: userSchema.parse(body) }; } /** * Exchanges a refresh token for new tokens. Resolves `null` when the backend * rejects the token, and throws for any other failure. */ export async function refresh(refreshToken: string, signal: AbortSignal) { const response = await post("/auth/refresh", { refreshToken }, { signal }); // DummyJSON answers 403 for an invalid token and 401 for a missing one. if (response.status === 401 || response.status === 403) return null; if (!response.ok) throw new Error(`Refresh failed (${response.status})`); return tokensSchema.parse(await response.json()); } ``` The schemas do two jobs. They check what the backend sent, and they give the rest of the app its types. Requests omit `credentials: "include"`, because Session kit keeps tokens out of cookies and attaches them itself. ## 2. Create the client The client is the one object that holds the session. Tell it what a user is, where to save the session, and how to refresh: ```ts title="src/auth.ts" import { createAuth, webStorage } from "@monarcode/session-kit"; import { login, refresh, userSchema } from "./backend.js"; export const auth = createAuth({ // Keeps this app's saved session apart from any other on the same origin. name: "my-app", // Every user is checked against this schema, and typed by it. user: userSchema, // Where the session is saved: `localStorage`. storage: webStorage(), // How expired access is replaced. refresh: ({ refreshToken, signal }) => refresh(refreshToken, signal), }); /** Signs in with the backend, then hands the result to Session kit. */ export async function signInWithPassword(username: string, password: string) { const tokens = await login(username, password); await auth.signIn(tokens); } export type AppAuth = typeof auth; ``` `refresh` tells two failures apart. `null` means the backend rejected the refresh token, so the user is signed out. A throw means something else failed, so the session is kept and the error reported. That split is why an outage doesn't sign anyone out. [Refresh](https://session-kit.pages.dev/docs/configuration/refresh) has the details, and [Storage](https://session-kit.pages.dev/docs/configuration/storage) lists the other places a session can be saved. Create the client once, at module level, as this file does. Give each client in an app its own `name`. DummyJSON's tokens are JWTs, so their expiry comes from the `exp` and `iat` claims. [Expiry](https://session-kit.pages.dev/docs/configuration/expiry) covers backends that say it another way. ## 3. Add a sign-in form `signInWithPassword` is all a form needs. The form has no Session kit code of its own: ```tsx title="src/sign-in.tsx" import { useState, type FormEvent } from "react"; import { signInWithPassword } from "./auth.js"; export function SignIn() { const [error, setError] = useState(""); const [pending, setPending] = useState(false); async function submit(event: FormEvent) { event.preventDefault(); const form = new FormData(event.currentTarget); setError(""); setPending(true); try { await signInWithPassword( String(form.get("username")), String(form.get("password")), ); } catch (cause) { setError(cause instanceof Error ? cause.message : "Could not sign in"); } finally { setPending(false); } } return (

Sign in

{/* DummyJSON's documented test user. */} {error &&

{error}

}
); } ``` The form doesn't have to do anything when it succeeds. Signing in changes the client's state, and the next step renders from that state. With a router, the sign-in page's guard runs again and sends the user on. ## 4. Read the session in React The hooks come from `@monarcode/session-kit/react`. Register your client's type once, where you render `AuthProvider`, and they carry your user. Then render by `status`. Each case is one of the states in [How it works](https://session-kit.pages.dev/docs#how-it-works). By the last case, TypeScript has narrowed `user` to a value that's set: ```tsx title="src/app.tsx" import { AuthProvider, useAuth, useAuthClient, } from "@monarcode/session-kit/react"; import { auth } from "./auth.js"; import { SignIn } from "./sign-in.js"; // Types `AuthProvider` and the hooks with this client. declare module "@monarcode/session-kit" { interface Register { auth: typeof auth; } } export function App() { return ( ); } function Screen() { const { status, user } = useAuth(); const client = useAuthClient(); switch (status) { case "initializing": return

Restoring your session…

; case "unauthenticated": return ; case "unavailable": // Access couldn't be restored or refreshed: offer another try. return ( ); default: // Signed in: "authenticated", or "refreshing" with the user on screen. return (

Welcome, {user.firstName}

); } } /** The latest failure, such as a refresh the backend couldn't answer. */ function AuthError() { const error = useAuth((state) => state.error); return error ?

{error.message}

: null; } ``` Each part of this file has a job: - `AuthProvider` mounts the client. It restores the saved session, starts the refresh timer, and follows other tabs. - `useAuth()` returns the whole state. `useAuth(selector)` returns a part of it, and re-renders only when that part changes, as `AuthError` does. - `useAuthClient()` returns the client, for actions. - With more than one client in an app, create hooks for each with `createAuthHooks()` instead. - `isSignedIn(state)` is `true` while `authenticated` or `refreshing`. Pass it to `useAuth` to read only that: `useAuth(isSignedIn)`. It comes from `@monarcode/session-kit`. - `retry` and `signOut` reject when they fail, and publish the same failure in `state.error`. `AuthError` shows it, so the handlers have nothing left to do. With [`locking`](https://session-kit.pages.dev/docs/client/locking), give `locked` a case of its own. It has a user too, but no usable access. That's a working app. To check it: 1. Sign in. The welcome message appears. 2. Reload the page. You're still signed in. 3. Open the app in a second tab, and sign out in one of them. Both tabs show the sign-in form. ## 5. Call your API `createAuthFetch` makes a `fetch` that sends the access token to one origin, and to no other: ```ts title="src/api.ts" import { createAuthFetch } from "@monarcode/session-kit/http"; import { auth } from "./auth.js"; import { API, userSchema } from "./backend.js"; export const authFetch = createAuthFetch(auth, { baseUrl: `${API}/` }); /** The signed-in user, fresh from the backend. */ export async function fetchProfile() { const response = await authFetch("auth/me"); if (!response.ok) throw new Error(`Profile failed (${response.status})`); return userSchema.parse(await response.json()); } ``` When a request gets a 401, `authFetch` refreshes, then sends a GET or HEAD again. Other methods are never sent twice. [Authenticated requests](https://session-kit.pages.dev/docs/client/requests) covers the rest of its behaviour and its options. Using ky, axios or openapi-fetch? Pass `authFetch` as the client's `fetch`: see [Other HTTP clients](https://session-kit.pages.dev/docs/client/other-http-clients). ## 6. Protect your routes The client is the same everywhere. What changes is how each router keeps signed-out users away from private pages. To continue, follow the guide for your router: - [TanStack Router](https://session-kit.pages.dev/docs/setups/tanstack-router), and [TanStack Start in SPA mode](https://session-kit.pages.dev/docs/setups/tanstack-router#tanstack-start-in-spa-mode). - [React Router](https://session-kit.pages.dev/docs/setups/react-router), in declarative, data, or framework mode with `ssr: false`. - [Expo Router](https://session-kit.pages.dev/docs/setups/expo), for iOS and Android. Its guide starts from scratch. - Without a router, you are done. Render by `status`, as `Screen` does. With Effect, the same client becomes a service: see [Effect and effetch](https://session-kit.pages.dev/docs/effect). ## Next steps - [Using the client](https://session-kit.pages.dev/docs/client): what else the client does, such as updating the user and locking. - [Configuring the client](https://session-kit.pages.dev/docs/configuration): the rest of its options. # TanStack Router (https://session-kit.pages.dev/docs/setups/tanstack-router) In this guide, you add TanStack Router to the app from Getting started: a sign-in route, private routes that redirect signed-out users, and hooks that read the client from the Router. Guards run in `beforeLoad`, before a private route loads or renders. The hooks come from `@monarcode/session-kit/tanstack-router`, typed by the Router you register, and need no `AuthProvider`. A complete app is in [`examples/tanstack-router`](https://github.com/monarcode/session-kit/tree/main/examples/tanstack-router). [TanStack Start in SPA mode](#tanstack-start-in-spa-mode) lists what changes for Start. For TanStack Start with server rendering, follow [TanStack Start](https://session-kit.pages.dev/docs/setups/tanstack-start) instead. ## Before you begin Follow [Getting started](https://session-kit.pages.dev/docs/getting-started) through step 3. This guide uses its client in `src/auth.ts` and its form in `src/sign-in.tsx`. ## Installation To install the packages, run the following commands: ```npm npm install @monarcode/session-kit @tanstack/react-router zod npm install -D @tanstack/router-plugin ``` This page uses file-based routing, so add the Router plugin before the React plugin in `vite.config.ts`: ```ts import { tanstackRouter } from "@tanstack/router-plugin/vite"; import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; export default defineConfig({ plugins: [ tanstackRouter({ target: "react", autoCodeSplitting: true }), react(), ], }); ``` The plugin generates `src/routeTree.gen.ts` from the files under `src/routes`. ## 1. Type the root route The root route declares that Router context holds the client, so every guard gets a typed `context.auth`: ```tsx title="src/routes/__root.tsx" import { useAuth } from "@monarcode/session-kit/tanstack-router"; import { createRootRouteWithContext, Outlet } from "@tanstack/react-router"; import type { AppAuth } from "../auth.js"; export const Route = createRootRouteWithContext<{ auth: AppAuth }>()({ component: Root, }); function Root() { // Shows failures that outlive the page that caused them, such as a sign-out // that couldn't clear storage. const error = useAuth((state) => state.error); return ( <> {error &&

{error.message}

} ); } ``` ## 2. Add the sign-in route The sign-in route renders the form from Getting started. Its guard sends a signed-in user on to the page they asked for, and it runs again after a sign-in, so the form doesn't navigate: ```tsx title="src/routes/login.tsx" import { safeReturnTo } from "@monarcode/session-kit"; import { redirectIfSignedIn } from "@monarcode/session-kit/tanstack-router"; import { createFileRoute } from "@tanstack/react-router"; import { SignIn } from "../sign-in.js"; export const Route = createFileRoute("/login")({ validateSearch: (search: Record) => ({ redirectTo: safeReturnTo(search.redirectTo), }), beforeLoad: ({ context, search }) => redirectIfSignedIn(context.auth, { redirectTo: search.redirectTo }), component: SignIn, }); ``` `safeReturnTo` accepts only same-origin paths and returns `/` for anything else, including the sign-in page itself, so a stale `redirectTo` can't loop. If sign-in isn't at `/login`, pass the same `{ loginPath }` to `safeReturnTo`, `redirectIfSignedIn` and `requireSession`. `redirectIfSignedIn` sends signed-in users on to `redirectTo`, checked the same way. `connectAuth` runs it again after a sign-in. ## 3. Guard private routes Guard private routes with a pathless layout. Its child routes are guarded without `authenticated` appearing in their URLs: ```tsx title="src/routes/_authenticated.tsx" import { requireSession, SessionOutlet, useAuthClient, } from "@monarcode/session-kit/tanstack-router"; import { createFileRoute, type ErrorComponentProps, } from "@tanstack/react-router"; export const Route = createFileRoute("/_authenticated")({ beforeLoad: ({ context, location }) => requireSession(context.auth, { location }), errorComponent: SessionError, component: () => Checking session…

} />, }); /** Shown after a failed refresh; a repeated failure stays in state.error. */ function SessionError({ error }: ErrorComponentProps) { const auth = useAuthClient(); return (

{error instanceof Error ? error.message : "Something went wrong"}

); } ``` `requireSession` redirects signed-out users to `/login`, or the `loginPath` you pass, with `redirectTo` set to where they were going, and puts `{ session }` in route context. A failed refresh reaches the error component instead of a redirect, because the user is still signed in. `SessionOutlet` renders child routes while the session the guard accepted is still the signed-in one. After a sign-out or an account switch it shows `pending` until the guard runs again, and remounts child routes for the new account, so nothing from the previous one stays on screen. Profile updates and token refreshes keep child routes mounted. Child routes read the session from route context, where `user` is typed and never `null`: ```tsx title="src/routes/_authenticated/index.tsx" import { useAuthClient } from "@monarcode/session-kit/tanstack-router"; import { createFileRoute } from "@tanstack/react-router"; export const Route = createFileRoute("/_authenticated/")({ component: Home, }); function Home() { const { session } = Route.useRouteContext(); const auth = useAuthClient(); return (

Welcome, {session.user.firstName}

); } ``` ## 4. Create and connect the Router Create the Router with the client in its context, then connect the two: ```tsx title="src/router.tsx" import { connectAuth } from "@monarcode/session-kit/tanstack-router"; import { createRouter } from "@tanstack/react-router"; import { auth } from "./auth.js"; import { routeTree } from "./routeTree.gen.js"; export const router = createRouter({ routeTree, context: { auth }, defaultPendingComponent: () =>

Loading…

, }); // Restores the session, runs expiry timers and tab sync, and re-runs guards // when auth changes. Call it once, beside the Router. export const disconnectAuth = connectAuth(router); declare module "@tanstack/react-router" { interface Register { router: typeof router; } } ``` `connectAuth` does for the Router what `AuthProvider` does for a plain React app, and one thing more. When the session changes, it runs the guards again, so a sign-out in another tab redirects this one. Registering the Router also types `useAuth` and `useAuthClient` with your client. Until it's registered, they're typed `never`. ## 5. Render the app Render the Router: ```tsx title="src/main.tsx" import { StrictMode } from "react"; import { RouterProvider } from "@tanstack/react-router"; import { createRoot } from "react-dom/client"; import { router } from "./router.js"; createRoot(document.getElementById("root")!).render( , ); ``` To check the app: 1. Signed out, open `/`. The Router sends you to `/login`. 2. Sign in. You're back on `/`, with the welcome message. 3. Open a second tab and sign out there. The first tab goes to `/login`. ## Layouts around guarded pages A guarded route often has UI of its own around its pages, such as a sidebar and a top bar. Render it in the route's component, with `SessionOutlet` where the pages go: ```tsx title="src/dashboard.tsx" import { useState } from "react"; import { SessionOutlet, useAuth } from "@monarcode/session-kit/tanstack-router"; /** The guarded route's component: `component: Dashboard`. */ export function Dashboard() { // From state, not route context: this stays mounted through a sign-out. const user = useAuth((state) => state.user); const sessionId = useAuth((state) => state.sessionId); return (
{user ? `Signed in as ${user.firstName}` : null}
{/* Keyed by session, so one account's choices never show for the next. */}
Checking session…

} />
); } function Sidebar() { const [pinned, setPinned] = useState(false); return ( ); } ``` In the guarded route's component, `SessionOutlet` reads the session that route's guard accepted. Three things follow from where it sits: - **It protects only what it renders.** After a sign-out or an account switch, the pages are swapped for `pending` and remounted, but the layout around them stays mounted until the guard redirects. - **Read the user from state there.** In the layout, use `useAuth((state) => state.user)` and handle `null`. Route context's `session.user` is the session the guard accepted, which for that moment is the previous one. Inside the pages, route context's session is always the signed-in one. - **Key what belongs to an account.** Layout UI that holds state for one account, such as a draft or an open panel, takes the session as its `key`, so the next account starts fresh. ## TanStack Start in SPA mode TanStack Start's SPA mode is TanStack Router with a build-time shell. Steps 2 and 3 apply as they are. Installation, step 1, step 4 and step 5 change: - Install `@tanstack/react-start` instead of `@tanstack/router-plugin`. - In `vite.config.ts`, use Start's plugin, with SPA mode on, instead of the Router plugin: `plugins: [tanstackStart({ spa: { enabled: true } }), react()]`. - Give the root route a `shellComponent` that renders ``, `` and ``, as the example app does. Start renders the app from it, so there is no `src/main.tsx`. In `src/router.tsx`, create the Router and connect auth inside `getRouter()`, which Start calls: ```tsx import { connectAuth } from "@monarcode/session-kit/tanstack-router"; import { createRouter } from "@tanstack/react-router"; import { auth } from "./auth.js"; import { routeTree } from "./routeTree.gen.js"; export function getRouter() { const router = createRouter({ routeTree, context: { auth }, defaultPendingComponent: () =>

Loading…

, }); // Does nothing while the build prerenders the shell. connectAuth(router); return router; } declare module "@tanstack/react-router" { interface Register { router: ReturnType; } } ``` And turn server rendering off for routes, in `src/start.ts`: ```ts title="src/start.ts" import { createStart } from "@tanstack/react-start"; // The session is in browser storage, so guards must run in the browser. export const startInstance = createStart(() => ({ defaultSsr: false, })); ``` Start's own server still answers requests in SPA mode, and it has no saved session to check. `connectAuth` does nothing while the Router renders on a server, so the build-time shell shows your pending component. Complete apps are in [`examples/tanstack-start-spa`](https://github.com/monarcode/session-kit/tree/main/examples/tanstack-start-spa) and, written with Effect and effetch, in [`examples/tanstack-start-spa-effetch`](https://github.com/monarcode/session-kit/tree/main/examples/tanstack-start-spa-effetch). ## Things to know - **The first render.** With synchronous storage and a synchronous schema, `connectAuth` restores the saved session before the first render, so a signed-in user never sees the sign-in page. - **Stopping.** Call `disconnectAuth` on app teardown or hot-module disposal. It stops background work without signing out. In Start's SPA mode, `getRouter()` doesn't keep it, as the Router lasts as long as the page. - **A failed refresh is not a redirect.** `requireSession` rethrows it for the route's error component, which can offer `auth.retry()`. The user is still signed in. - **Locked sessions.** [Locking](https://session-kit.pages.dev/docs/client/locking) is for native apps, so the guards treat a locked session as signed out. - **Cached data.** `state.sessionId` is new on every sign-in. Scope private cached data, such as TanStack Query results, to it. ## Next steps - [Call your API](https://session-kit.pages.dev/docs/getting-started#5-call-your-api) with `authFetch`, as in Getting started, or with [ky, axios or openapi-fetch](https://session-kit.pages.dev/docs/client/other-http-clients). It works unchanged with the Router. - [Using the client](https://session-kit.pages.dev/docs/client): updating the user, signing out, and the client's other methods. - [Effect and effetch](https://session-kit.pages.dev/docs/effect): the same client as an Effect service. # TanStack Start (https://session-kit.pages.dev/docs/setups/tanstack-start) In this guide, you keep the session in an encrypted cookie with TanStack Start. The server signs in, refreshes the tokens before rendering, and sends the signed-in page first, so the browser never shows a "Checking session…" state or a flash of signed-out content. The browser holds a small client that mirrors the session, and the [TanStack Router hooks and guards](https://session-kit.pages.dev/docs/setups/tanstack-router) work with it. [Server rendering](https://session-kit.pages.dev/docs/server-rendering) explains how the pieces fit. For TanStack Start in SPA mode, where the browser keeps the session, follow [TanStack Start in SPA mode](https://session-kit.pages.dev/docs/setups/tanstack-router#tanstack-start-in-spa-mode) instead. A complete app is in [`examples/tanstack-start-ssr`](https://github.com/monarcode/session-kit/tree/main/examples/tanstack-start-ssr). ## Before you begin - Node 22.12 or newer, which Start needs. - A secret of 32 or more random characters to seal the cookie, set as `SESSION_SECRET` on the server. To make one, run `openssl rand -base64 32`. ## Installation To install the packages, run the following command: ```npm npm install @monarcode/session-kit @tanstack/react-start @tanstack/react-router zod ``` ## 1. Create the server engine `createServerAuth` from `@monarcode/session-kit/server` keeps the session in a cookie sealed with your `secret`, and refreshes it with your backend. `startAuth` gives the session of the request Start is handling. Create both in a server-only module, so Start refuses to bundle it for the browser: ```ts title="src/auth/server.ts" import "@tanstack/react-start/server-only"; import { createServerAuth } from "@monarcode/session-kit/server"; import { startAuth } from "@monarcode/session-kit/tanstack-start/server"; import { z } from "zod"; const API = "https://api.example.com"; const userSchema = z.object({ id: z.number(), firstName: z.string() }); const tokens = z.object({ accessToken: z.string(), refreshToken: z.string() }); export const serverAuth = createServerAuth({ name: "app", user: userSchema, secret: process.env.SESSION_SECRET!, refresh: async ({ refreshToken, signal }) => { const response = await fetch(`${API}/auth/refresh`, { method: "POST", signal, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refreshToken }), }); // The backend rejected the refresh token: sign out. if (response.status === 401) return null; if (!response.ok) throw new Error(`Refresh failed (${response.status})`); return tokens.parse(await response.json()); }, }); /** The session of the request Start is handling, found on each call. */ export const session = startAuth(serverAuth); /** Signs in with your backend, from the server. */ export async function login(username: string, password: string) { const response = await fetch(`${API}/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }), }); const body: unknown = await response.json().catch(() => null); if (!response.ok) { // The backend says why, such as wrong credentials, in `message`. const failure = z.object({ message: z.string() }).safeParse(body); throw new Error( failure.success ? failure.data.message : `Sign-in failed (${response.status})`, ); } return { ...tokens.parse(body), user: userSchema.parse(body) }; } ``` [Server sessions](https://session-kit.pages.dev/docs/server-rendering/server-sessions#the-cookie) covers its other options, rotating the secret, signing out, and the limits every setup shares. ## 2. Add the session route and server functions The browser's client signs out and gets the access token through a session route. `sessionRoute` from `@monarcode/session-kit/tanstack-start/server` makes its `GET` and `POST` handlers, for a server route. Start leaves `server.handlers` out of the browser bundle, `serverAuth` included: ```ts title="src/routes/api/session.ts" import { sessionRoute } from "@monarcode/session-kit/tanstack-start/server"; import { createFileRoute } from "@tanstack/react-router"; import { serverAuth } from "../../auth/server"; export const Route = createFileRoute("/api/session")({ server: { handlers: sessionRoute(serverAuth) }, }); ``` It answers your own site's pages only, so another site can't sign the user out: see [The session route](https://session-kit.pages.dev/docs/security#the-session-route). Two server functions do the rest. Signing in takes your form's input. Reading the session is a server function too, because `loadSession` reads it while Start renders on the server, where the client can't call a route on its own site. `session` finds the request each time it's called, so one object serves every request: ```ts title="src/auth/functions.ts" import { createServerFn } from "@tanstack/react-start"; import { z } from "zod"; import { login, session } from "./server"; export const getSessionFn = createServerFn().handler(() => session.getSession(), ); export const signInFn = createServerFn({ method: "POST" }) .validator(z.object({ username: z.string(), password: z.string() })) .handler(async ({ data }) => { const tokens = await login(data.username, data.password); return session.signIn(tokens); }); ``` `getSession` and `signIn` resolve `{ sessionId, user }`, never tokens, so their results are safe to send to the browser. Start's compiler replaces each handler with a request in the browser bundle, and leaves `session` out of it. ## 3. Refresh before rendering Request middleware refreshes access that is about to expire before any route or server function reads it, while the response can still set the cookie. Once Start starts streaming a page, a cookie set later is lost, so a refresh then, such as in a deferred loader's promise, would spend the refresh token and drop the new one. `refreshSession` from `@monarcode/session-kit/tanstack-start/server` does the refresh. Pass it to Start's own `createMiddleware().server()`. Start bundles `src/start.ts` for the browser too, but strips what's inside `.server()`, so `serverAuth` stays on the server: ```ts title="src/start.ts" import { refreshSession } from "@monarcode/session-kit/tanstack-start/server"; import { createCsrfMiddleware, createMiddleware, createStart, } from "@tanstack/react-start"; import { serverAuth } from "./auth/server"; const sessionMiddleware = createMiddleware({ type: "request" }).server( refreshSession(serverAuth), ); export const startInstance = createStart(() => ({ requestMiddleware: [ createCsrfMiddleware({ filter: ({ handlerType }) => handlerType === "serverFn", }), sessionMiddleware, ], })); ``` It refreshes access that expires within 60 seconds. Access tokens that live a minute or less are refreshed on every request, so pass a smaller `within` for them, such as `refreshSession(serverAuth, { within: 15 })`. A failed refresh doesn't fail the request. Routes read the session as usual, and an error there reaches the route's error component. An app that defines `createStart` has no CSRF protection for its server functions until it adds `createCsrfMiddleware`. Its `filter` keeps the check to server-function calls. Unfiltered, it also refuses page loads from links on other sites and from the address bar. It leaves out server routes. `sessionRoute` refuses other sites itself, and a server route of your own that changes the session must too: see [The session route](https://session-kit.pages.dev/docs/security#the-session-route). ## 4. Create the browser client `createMirrorAuth` makes the client that routes and components use. Create it in `getRouter`, which Start calls once per request on the server and once in the browser, never at module level. `connectSession` sends the session the server read to the browser with the page, and runs guards again when it changes there. First, the client: ```ts title="src/auth/client.ts" import { createMirrorAuth } from "@monarcode/session-kit"; import { getSessionFn, signInFn } from "./functions"; export function createAppAuth() { return createMirrorAuth({ name: "app", session: null, // filled by loadSession, then by hydration endpoint: "/api/session", revalidate: () => getSessionFn(), signIn: (data: { username: string; password: string }) => signInFn({ data }), }); } export type AppAuth = ReturnType; ``` Then the Router, which creates it and connects it: ```tsx title="src/router.tsx" import { connectSession } from "@monarcode/session-kit/tanstack-start"; import { createRouter } from "@tanstack/react-router"; import { createAppAuth } from "./auth/client"; import { routeTree } from "./routeTree.gen"; export function getRouter() { const auth = createAppAuth(); const router = createRouter({ routeTree, context: { auth } }); connectSession(router); return router; } declare module "@tanstack/react-router" { interface Register { router: ReturnType; } } ``` Let TypeScript infer the client's types from its options. With `createMirrorAuth(…)`, `signIn` would be left out. A `dehydrate` and a `hydrate` of your own, such as for a query cache, go to `createRouter` as usual. `connectSession` keeps them. ## 5. Load the session, then guard routes The root route's `beforeLoad` reads the request's session into the client with `loadSession`, before any guard runs. In the browser, the hydrated session is already there, so it doesn't call the server: ```tsx title="src/routes/__root.tsx" import { loadSession } from "@monarcode/session-kit/tanstack-start"; import { createRootRouteWithContext, Outlet } from "@tanstack/react-router"; import type { AppAuth } from "../auth/client"; export const Route = createRootRouteWithContext<{ auth: AppAuth }>()({ beforeLoad: ({ context }) => loadSession(context.auth), component: Outlet, }); ``` When the server can't read the session, such as after a failed refresh, `loadSession` rejects, and the root route's `errorComponent` shows. In the example app, it offers a retry with `router.invalidate()`, which runs `loadSession` again. Guards are the ones from `@monarcode/session-kit/tanstack-router`. On the server, a redirect is a real HTTP redirect: ```tsx title="src/routes/_authenticated.tsx" import { requireSession, SessionOutlet, } from "@monarcode/session-kit/tanstack-router"; import { createFileRoute } from "@tanstack/react-router"; export const Route = createFileRoute("/_authenticated")({ beforeLoad: ({ context, location }) => requireSession(context.auth, { location }), component: () => Loading…

} />, }); ``` [Guard private routes](https://session-kit.pages.dev/docs/setups/tanstack-router#3-guard-private-routes) explains `SessionOutlet`. Its error component isn't needed here, as `loadSession` reports a failed refresh first. Guards decide what a page shows. Server functions that return private data must check the session themselves, with `session.getSession()` or `session.credentials.get()`. ## Sign in The client's `signIn` sends what it's given to your `signInFn`, then adopts the session it returns, re-runs guards and tells other tabs: ```ts title="src/sign-in.ts" import type { AppAuth } from "./auth/client"; /** Signs in, and resolves the message to show when it fails. */ export async function signIn( auth: AppAuth, username: string, password: string, ): Promise { try { await auth.signIn({ username, password }); return null; } catch (error) { // What signInFn threw, such as your backend's message for a wrong // password. return error instanceof Error ? error.message : "Could not sign in"; } } ``` In a component, `useAuthClient` from `@monarcode/session-kit/tanstack-router` gives you `auth`, from Router context. Give the sign-in route the `redirectIfSignedIn` guard, as in TanStack Router's [Add the sign-in route](https://session-kit.pages.dev/docs/setups/tanstack-router#2-add-the-sign-in-route). `connectSession` runs it again after a sign-in, so the form doesn't navigate. In this guide, the server signs in with your backend, so the user in the cookie comes from the backend. If the browser signs in with the backend itself, for example through a provider's SDK, have `signInFn` take the tokens instead, as [Hybrid setup](https://session-kit.pages.dev/docs/server-rendering/hybrid) shows. ## Sign out The client's `signOut()` signs out on the server through the session route, which clears the cookie and calls your `revoke` option, when `createServerAuth` has one. It then signs out this tab and tells the others. Guards run again, so `requireSession` sends the user to the sign-in page: ```tsx title="src/sign-out.tsx" import { useAuthClient } from "@monarcode/session-kit/tanstack-router"; export function SignOutButton() { const auth = useAuthClient(); // A failed sign-out keeps the session, and shows in `state.error`. return ( ); } ``` ## Call your API Call your API with `createAuthFetch` from `@monarcode/session-kit/http`. Given `session`, it sends the access token of the request Start is handling, refreshed first when it has expired. After a 401, it renews the token, saves it in the cookie, and sends a `GET` again. Make it once, in a server-only module: ```ts title="src/auth/api-fetch.ts" import "@tanstack/react-start/server-only"; import { createAuthFetch } from "@monarcode/session-kit/http"; import { session } from "./server"; export const apiFetch = createAuthFetch(session, { baseUrl: "https://api.example.com/", }); ``` Then call it in a server function. Signed out, it rejects with `UNAUTHENTICATED`: ```ts title="src/orders.ts" import { createServerFn } from "@tanstack/react-start"; import { z } from "zod"; import { apiFetch } from "./auth/api-fetch"; export const getOrdersFn = createServerFn().handler(async () => { const response = await apiFetch("orders"); if (!response.ok) throw new Error(`Orders failed (${response.status})`); return z.array(z.object({ id: z.number() })).parse(await response.json()); }); ``` With an HTTP client of your own, such as ky or axios, see [Other HTTP clients](https://session-kit.pages.dev/docs/client/other-http-clients). A client without a `fetch` option reads the token with `session.credentials.get()`. To call the API from the browser too, with `createAuthFetch` or effetch's `sessionBearer`, opt in with `clientCredentials: true` in `createServerAuth`. The session route then serves the access token, and the client's `credentials` get it there. The browser keeps the access token in memory, never the refresh token. The example app does this. ## Things to know - **Signing out ends the session only with `revoke`.** Without it, a copy of the cookie made before sign-out still works: see [Sign-out](https://session-kit.pages.dev/docs/server-rendering/server-sessions#sign-out). - **One session per request.** Each call to `session` reads the request Start is handling, and calls during one request share what it read and one refresh. Outside a request, such as in the browser, they reject with `INVALID_OPTIONS`. - **Caching.** Pages and server functions that read the session differ per user. `refreshSession` sends `Cache-Control: private, no-store` with each response that doesn't set its own, so a CDN or proxy doesn't cache them. To leave every response as it is, pass `{ cacheControl: false }`. - **Prerendering.** A prerendered page renders at build time, without a cookie, so it shows the signed-out state. Don't prerender pages that read the session. - **Tabs.** Signing in or out in one tab reaches the others, which ask the server again. So does returning to a tab after a while. - **More.** Several server instances, the cookie's size, and plain http during development: see [Server sessions](https://session-kit.pages.dev/docs/server-rendering/server-sessions). ## Next steps - [Server sessions](https://session-kit.pages.dev/docs/server-rendering/server-sessions): the cookie, signing out, and the limits every server-rendered setup shares. - [Using the client](https://session-kit.pages.dev/docs/client): what the browser client can do. - [Effect on the server](https://session-kit.pages.dev/docs/effect#on-the-server): run Effects with a request's session. # React Router (https://session-kit.pages.dev/docs/setups/react-router) In this guide, you add React Router to the app from Getting started, in the mode your app uses. React Router has three modes, and Session kit guards each one the way that mode works: | Mode | The guard | Beside it | | ---------------------------- | -------------------------------------------- | -------------------------------------- | | Declarative | `SessionOutlet` | Nothing | | Data | `requireSessionLoader` as the `loader` | `SessionOutlet`, `useAuthRevalidation` | | Framework, with `ssr: false` | `requireSessionLoader` as the `clientLoader` | `SessionOutlet`, `useAuthRevalidation` | Each mode has a complete example app. For framework mode with server rendering, follow [React Router with server rendering](https://session-kit.pages.dev/docs/setups/react-router-ssr) instead. ## Before you begin Follow [Getting started](https://session-kit.pages.dev/docs/getting-started) through step 3. This guide uses its client in `src/auth.ts` and its form in `src/sign-in.tsx`. ## Installation To install the packages, run the following command: ```npm npm install @monarcode/session-kit react-router zod ``` React Router 7 and 8 both work. ## Add a page to protect In declarative and data mode, `AuthProvider` goes around the router. In framework mode, it goes in the root route. It mounts the client in a layout effect, so with `localStorage` and a synchronous schema, the session is restored before the first paint. Nothing starts on a server. Register the client where you render it, as step 4 of Getting started shows, and the hooks from `@monarcode/session-kit/react` carry your user. Every mode on this page protects the same page: ```tsx title="src/home.tsx" import { useAuth, useAuthClient } from "@monarcode/session-kit/react"; export function Home() { const user = useAuth((state) => state.user); const auth = useAuthClient(); return (

Welcome, {user?.firstName}

); } ``` ## Declarative mode Declarative mode has no loaders, so `SessionOutlet` is the guard. Render it as a layout route's element, `}>`, and the routes inside it render only while someone is signed in: ```tsx title="src/main.tsx" import { StrictMode } from "react"; import { BrowserRouter, Navigate, Route, Routes, useSearchParams, } from "react-router"; import { isSignedIn, safeReturnTo } from "@monarcode/session-kit"; import { AuthProvider, useAuth } from "@monarcode/session-kit/react"; import { SessionOutlet } from "@monarcode/session-kit/react-router"; import { createRoot } from "react-dom/client"; import { auth } from "./auth.js"; import { Home } from "./home.js"; import { SignIn } from "./sign-in.js"; // Types `AuthProvider` and the hooks with this client. declare module "@monarcode/session-kit" { interface Register { auth: typeof auth; } } /** The guard: its child routes render only while someone is signed in. */ function Private() { return ( Checking session…

} unavailable={({ error, retry }) => (

{error?.message ?? "Can't reach the server."}

)} /> ); } /** Signing in changes the state, which renders this again and redirects. */ function Login() { const signedIn = useAuth(isSignedIn); const [search] = useSearchParams(); if (signedIn) return ; return ; } createRoot(document.getElementById("root")!).render( } /> }> } /> , ); ``` `SessionOutlet` shows `pending` while the saved session is restored, sends signed-out users to `loginPath` (`/login` unless you pass another) with `redirectTo` set to where they were going, and renders `unavailable` after a failed refresh. Without `unavailable`, the error is thrown to the nearest error boundary. `safeReturnTo` accepts only same-origin paths and returns `/` for anything else, including the sign-in page itself, so a stale `redirectTo` can't loop. A complete app is in [`examples/react-router-declarative`](https://github.com/monarcode/session-kit/tree/main/examples/react-router-declarative). ## Data mode In data mode, loaders run before a route renders, so the guard goes there. A signed-out user is redirected before a private route renders: ```tsx title="src/router.tsx" import { createBrowserRouter, Outlet, useRouteError } from "react-router"; import { useAuthClient } from "@monarcode/session-kit/react"; import { redirectIfSignedInLoader, requireSessionLoader, SessionOutlet, useAuthRevalidation, } from "@monarcode/session-kit/react-router"; import { auth } from "./auth.js"; import { Home } from "./home.js"; import { SignIn } from "./sign-in.js"; /** Runs the loaders again when auth changes, and with them the guards. */ function Root() { useAuthRevalidation(); return ; } /** Shown after a failed refresh; a repeated failure shows here again. */ function SessionError() { const error = useRouteError(); const client = useAuthClient(); return (

{error instanceof Error ? error.message : "Something went wrong"}

); } export const router = createBrowserRouter([ { element: , HydrateFallback: () =>

Checking session…

, children: [ { path: "/login", loader: redirectIfSignedInLoader(auth), element: , }, { loader: requireSessionLoader(auth), errorElement: , element: Checking session…

} />, children: [{ index: true, element: }], }, ], }, ]); ``` Then render the router inside the provider, in `src/main.tsx`: ```tsx import { StrictMode } from "react"; import { RouterProvider } from "react-router/dom"; import { AuthProvider } from "@monarcode/session-kit/react"; import { createRoot } from "react-dom/client"; import { auth } from "./auth.js"; import { router } from "./router.js"; // Types `AuthProvider` and the hooks with this client. declare module "@monarcode/session-kit" { interface Register { auth: typeof auth; } } createRoot(document.getElementById("root")!).render( , ); ``` Three pieces work together: - **`requireSessionLoader(auth)`** is the route's loader. It redirects signed-out users to `/login` with `redirectTo`, and returns `{ session }` as the route's loader data. Pass `{ loginPath }` for another sign-in page. A failed refresh is rethrown for the route's error element instead, because the user is still signed in. A loader that does more calls `requireSession` instead, as [Guards in loaders](#guards-in-loaders) shows. - **`useAuthRevalidation`** runs the loaders again after a sign-in, a sign-out, an account switch, or a profile update, so the guards run again. Call it once, in the root route. - **`SessionOutlet`**, as the guarded route's element, renders child routes only while the session the loader accepted is still the signed-in one. When another account signs in, it shows `pending` until the loader has run for the new session, so no page sees the previous account's loader data. A complete app is in [`examples/react-router-data`](https://github.com/monarcode/session-kit/tree/main/examples/react-router-data). ## Framework mode with `ssr: false` Framework mode without a server is data mode with route modules. The guards are the same, in `clientLoader`. Keep the files from Getting started, and `home.tsx` from [Add a page to protect](#add-a-page-to-protect), in `app/`. Framework mode also needs `@react-router/dev`, which a framework-mode app already has. Turn server rendering off: ```ts // react-router.config.ts import type { Config } from "@react-router/dev/config"; export default { ssr: false } satisfies Config; ``` Provide the client, and watch for auth changes, in the root route: ```tsx title="app/root.tsx" import type { ReactNode } from "react"; import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router"; import { AuthProvider } from "@monarcode/session-kit/react"; import { useAuthRevalidation } from "@monarcode/session-kit/react-router"; import { auth } from "./auth.js"; // Types `AuthProvider` and the hooks with this client. declare module "@monarcode/session-kit" { interface Register { auth: typeof auth; } } export function Layout({ children }: { children: ReactNode }) { return ( {children} ); } /** Runs the client loaders again when auth changes, and with them the guards. */ function Revalidate() { useAuthRevalidation(); return ; } export default function App() { return ( ); } /** Prerendered into index.html, and shown until the client loaders finish. */ export function HydrateFallback() { return

Checking session…

; } ``` Guard a layout route, and nest the private routes under it: ```tsx title="app/routes/protected.tsx" import { useRouteError } from "react-router"; import { useAuthClient } from "@monarcode/session-kit/react"; import { requireSessionLoader, SessionOutlet, } from "@monarcode/session-kit/react-router"; import { auth } from "../auth.js"; export const clientLoader = requireSessionLoader(auth); export default function Protected() { return Checking session…

} />; } /** Shown after a failed refresh; a repeated failure shows here again. */ export function ErrorBoundary() { const error = useRouteError(); const client = useAuthClient(); return (

{error instanceof Error ? error.message : "Something went wrong"}

); } ``` Add the sign-in route, which sends signed-in users on to `redirectTo`. Its loader runs again after a sign-in, so the form doesn't navigate: ```tsx title="app/routes/login.tsx" import { redirectIfSignedInLoader } from "@monarcode/session-kit/react-router"; import { auth } from "../auth.js"; import { SignIn } from "../sign-in.js"; export const clientLoader = redirectIfSignedInLoader(auth); export default SignIn; ``` In `app/routes.ts`, nest the routes the layout protects: ```ts import { index, layout, route, type RouteConfig, } from "@react-router/dev/routes"; export default [ route("login", "routes/login.tsx"), layout("routes/protected.tsx", [index("routes/home.tsx")]), ] satisfies RouteConfig; ``` `routes/home.tsx` is the page, as its default export: `export { Home as default } from "../home.js";`. `AuthProvider` mounts the client in an effect, so the build-time prerender of the root route starts nothing, and the browser restores the session. A complete app is in [`examples/react-router-framework-spa`](https://github.com/monarcode/session-kit/tree/main/examples/react-router-framework-spa). ## Guards in loaders Two functions guard a loader, in data mode and in framework mode. Both send a signed-out user to `/login`, with `redirectTo`, and resolve `{ session }`, typed with your user: - **`requireSessionLoader(auth)`** is the whole loader. Use it on a route that guards and loads nothing else, usually the layout route around your private pages, as in [Data mode](#data-mode). - **`requireSession(auth, { request })`** is a call inside a loader of your own. Use it when the loader loads data too: data only a signed-in user may load, or data that needs who is signed in. React Router runs the loaders of nested routes at the same time, so a page's loader doesn't wait for its layout's guard. A page whose loader calls your API checks the session itself: ```tsx title="app/routes/profile.tsx" import { useLoaderData } from "react-router"; import { requireSession } from "@monarcode/session-kit/react-router"; import { fetchProfile } from "../api.js"; import { auth } from "../auth.js"; export async function clientLoader({ request }: { request: Request }) { const { session } = await requireSession(auth, { request }); return { name: session.user.firstName, profile: await fetchProfile() }; } export default function Profile() { const { name, profile } = useLoaderData(); return (

{name}, your email is {profile.email}.

); } ``` A signed-out user is redirected before your API is called, rather than after a request that fails. Pass `requireSession` the same `loginPath` and `basename` as the layout's guard. The sign-in page has the same pair: `redirectIfSignedInLoader(auth)` as its loader, or `redirectIfSignedIn(auth, { redirectTo })` in a loader that does more. ## Layouts around guarded pages A guarded route often has UI of its own around its pages, such as a sidebar and a top bar. Render it in the guarded route's element, with `SessionOutlet` where the pages go: ```tsx title="src/dashboard.tsx" import { useAuth } from "@monarcode/session-kit/react"; import { SessionOutlet } from "@monarcode/session-kit/react-router"; /** The guarded route's element: `element: `. */ export function Dashboard() { // From state, not loader data: this stays mounted through a sign-out. const user = useAuth((state) => state.user); const sessionId = useAuth((state) => state.sessionId); return (
{user ? `Signed in as ${user.firstName}` : null}
{/* Keyed by session, so one account's choices never show for the next. */}
Checking session…

} />
); } ``` Three things follow from where `SessionOutlet` sits: - **It protects only what it renders.** After a sign-out it redirects to the sign-in page. After an account switch it remounts the pages, and in data and framework mode shows `pending` until the guard runs again. The layout around it stays mounted meanwhile. - **Read the user from state there.** In the layout, use `useAuth((state) => state.user)` and handle `null`. The loader's `session` is the one the guard accepted, which for that moment is the previous one. - **Key what belongs to an account.** Layout UI that holds state for one account, such as a draft or an open panel, takes the session as its `key`, so the next account starts fresh. ## Things to know - **Another sign-in page.** If sign-in isn't at `/login`, pass the same `loginPath` to `SessionOutlet`, `requireSessionLoader` (or `requireSession`), `redirectIfSignedInLoader` and `safeReturnTo`. - **A router `basename`.** Pass it to `requireSessionLoader` or `requireSession` too, so `redirectTo` leaves it out. Redirects add it again. `SessionOutlet` needs nothing. - **A failed refresh is not a redirect.** In declarative mode, `SessionOutlet` renders `unavailable`, or throws to the nearest error boundary without it. In data and framework mode, the guard rethrows it for the route's error element, which can offer `auth.retry()`. - **Locked sessions.** [Locking](https://session-kit.pages.dev/docs/client/locking) is for native apps, so the guards treat a locked session as signed out. - **Cached data.** `state.sessionId` is new on every sign-in. Scope private cached data, such as TanStack Query results, to it. ## Next steps - [Call your API](https://session-kit.pages.dev/docs/getting-started#5-call-your-api) with `authFetch`, as in Getting started, or with [ky, axios or openapi-fetch](https://session-kit.pages.dev/docs/client/other-http-clients). It works unchanged with the router. - [Using the client](https://session-kit.pages.dev/docs/client): updating the user, signing out, and the client's other methods. - [Effect and effetch](https://session-kit.pages.dev/docs/effect): the same client as an Effect service. # React Router with server rendering (https://session-kit.pages.dev/docs/setups/react-router-ssr) In this guide, you keep the session in an encrypted cookie in React Router's framework mode. An action signs in, a session route answers the browser, middleware refreshes the tokens, and loaders read the session. The first response already shows the signed-in page, so the browser never shows a "Checking session…" state or a flash of signed-out content. Components get a small client that mirrors the session, and the [hooks](https://session-kit.pages.dev/docs/getting-started#4-read-the-session-in-react) work with it. [Server rendering](https://session-kit.pages.dev/docs/server-rendering) explains how the pieces fit. For framework mode with `ssr: false`, follow [React Router](https://session-kit.pages.dev/docs/setups/react-router#framework-mode-with-ssr-false) instead. A complete app is in [`examples/react-router-framework-ssr`](https://github.com/monarcode/session-kit/tree/main/examples/react-router-framework-ssr). ## Before you begin - React Router's middleware: React Router 8, or 7.9 and later with `future.v8_middleware`. The code on this page is for React Router 8. On 7.14 and earlier, read the URL with `new URL(request.url)` where the code uses the `url` argument. - A secret of 32 or more random characters to seal the cookie, set as `SESSION_SECRET` on the server. To make one, run `openssl rand -base64 32`. `react-router build` doesn't need it. ## Installation To install the packages, run the following command: ```npm npm install @monarcode/session-kit react-router zod ``` ## 1. Create the server engine `createServerAuth` from `@monarcode/session-kit/server` keeps the session in a cookie sealed with your `secret`, and refreshes it with your backend. `routerAuth` gives a request's session by its `context`, and `requireSession` makes the guard that protects routes in step 4. Create them in a `.server` module, so React Router refuses to bundle it for the browser: ```ts title="app/auth/session.server.ts" import { requireSession, routerAuth, } from "@monarcode/session-kit/react-router/server"; import { createServerAuth } from "@monarcode/session-kit/server"; import { z } from "zod"; const API = "https://api.example.com"; const userSchema = z.object({ id: z.number(), firstName: z.string() }); const tokens = z.object({ accessToken: z.string(), refreshToken: z.string() }); export const serverAuth = createServerAuth({ name: "app", user: userSchema, secret: process.env.SESSION_SECRET!, refresh: async ({ refreshToken, signal }) => { const response = await fetch(`${API}/auth/refresh`, { method: "POST", signal, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refreshToken }), }); // The backend rejected the refresh token: sign out. if (response.status === 401) return null; if (!response.ok) throw new Error(`Refresh failed (${response.status})`); return tokens.parse(await response.json()); }, }); /** The request's session: `auth(context)` in middleware, loaders and actions. */ export const auth = routerAuth(serverAuth); /** Lets only signed-in users through to the routes it guards. */ export const signedIn = requireSession(serverAuth); /** Signs in with your backend, from the server. */ export async function login(username: string, password: string) { const response = await fetch(`${API}/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }), }); const body: unknown = await response.json().catch(() => null); if (!response.ok) { // The backend says why, such as wrong credentials, in `message`. const failure = z.object({ message: z.string() }).safeParse(body); throw new Error( failure.success ? failure.data.message : `Sign-in failed (${response.status})`, ); } return { ...tokens.parse(body), user: userSchema.parse(body) }; } ``` [Server sessions](https://session-kit.pages.dev/docs/server-rendering/server-sessions#the-cookie) covers its other options, rotating the secret, signing out, and the limits every setup shares. ## 2. Add the root route The root route's middleware is `sessionMiddleware(serverAuth)` from `@monarcode/session-kit/react-router/server`. It reads each request's cookies and sets the ones written on the response, redirects included, so `auth(context)` gives the request's session in middleware, loaders and actions. Then it refreshes access that is about to expire, before any loader reads it, and marks each response that doesn't set its own `Cache-Control` as `private, no-store`, so shared caches keep none. A failed refresh doesn't fail the request. Loaders read the session as usual, and an error there reaches the route's error boundary. `createMirrorAuth` makes the client that components use, from the session the root loader read and the session route that step 3 adds: ```ts title="app/auth/client.ts" import { createMirrorAuth, type SessionOf } from "@monarcode/session-kit"; // Types only: left out of the browser's bundle. import type { serverAuth } from "./session.server"; export type AppSession = SessionOf; export function createAppAuth(session: AppSession | null) { return createMirrorAuth({ name: "app", session, endpoint: "/auth/session", }); } export type AppAuth = ReturnType; ``` The root route runs the middleware and reads the session, and its component creates the client with `useMirrorAuth` from `@monarcode/session-kit/react`: once per request on the server, never at module level. It keeps the client and the server in step both ways. When the session changes in the browser, it calls `onChange`, here `revalidate` from `useRevalidator()`, which runs the loaders again. Register the client's type here, where you render `AuthProvider`, so the hooks carry your user: ```tsx title="app/root.tsx" import type { ReactNode } from "react"; import { Links, Meta, Outlet, Scripts, useLoaderData, useRevalidator, type LoaderFunctionArgs, } from "react-router"; import { AuthProvider, useMirrorAuth } from "@monarcode/session-kit/react"; import { sessionMiddleware } from "@monarcode/session-kit/react-router/server"; import { createAppAuth, type AppAuth } from "./auth/client"; import { auth, serverAuth } from "./auth/session.server"; // Types `AuthProvider` and the hooks with this client. declare module "@monarcode/session-kit" { interface Register { auth: AppAuth; } } export const middleware = sessionMiddleware(serverAuth); export async function loader({ context }: LoaderFunctionArgs) { return { session: await auth(context).getSession() }; } export function Layout({ children }: { children: ReactNode }) { return ( {children} ); } export default function App() { const { session } = useLoaderData(); const { revalidate } = useRevalidator(); const auth = useMirrorAuth({ create: createAppAuth, session, onChange: revalidate, }); return ( ); } ``` `getSession` resolves `{ sessionId, user }`, never tokens, so it's safe to return from a loader. After an action signs in or out, the loader reads the new session and the client adopts it. After a sign-out in the browser, here or in another tab, `onChange` runs the loaders again, so the server's redirects run. The client-side guides add `useAuthRevalidation` for this. Here, `useMirrorAuth` does it. ## 3. Sign in and out The sign-in route's action signs in with your backend and seals the result into the cookie. React Router 7.12 and later refuse route actions posted from other sites. On 7.9 to 7.11, refuse them yourself at the start of the action, as [The session route](https://session-kit.pages.dev/docs/security#the-session-route) explains. The form works before the page's scripts load: ```tsx title="app/routes/login.tsx" import { data, Form, redirect, useActionData, type ActionFunctionArgs, } from "react-router"; import { safeReturnTo } from "@monarcode/session-kit"; import { redirectIfSignedIn } from "@monarcode/session-kit/react-router/server"; import { auth, login, serverAuth } from "../auth/session.server"; // A signed-in user goes on to `redirectTo`. export const loader = redirectIfSignedIn(serverAuth); export async function action({ request, context, url }: ActionFunctionArgs) { const form = await request.formData(); try { const tokens = await login( String(form.get("username")), String(form.get("password")), ); await auth(context).signIn(tokens); } catch (error) { return data( { error: error instanceof Error ? error.message : "Sign-in failed" }, { status: 400 }, ); } throw redirect(safeReturnTo(url.searchParams.get("redirectTo"))); } export default function Login() { const result = useActionData(); return (
{result?.error &&

{result.error}

}
); } ``` A failed sign-in returns its message with status 400 for the form to show. React Router doesn't run the page's loaders again after a 4xx action. The browser's client reads the session and signs out through a session route, a resource route. `sessionRoute` from `@monarcode/session-kit/react-router/server` makes its loader and action: ```ts title="app/routes/auth.session.ts" import { sessionRoute } from "@monarcode/session-kit/react-router/server"; import { serverAuth } from "../auth/session.server"; const route = sessionRoute(serverAuth); export const loader = route.loader; export const action = route.action; ``` Add it to `app/routes.ts` as `route("auth/session", "routes/auth.session.ts")`. Export the loader and action one by one, as `app/routes/auth.session.ts` does. React Router's build fails on a destructured `export const { loader, action }`. The route answers your own site's pages only, so another site can't sign the user out: see [Security](https://session-kit.pages.dev/docs/security). ## 4. Protect routes Guard a layout route with `signedIn`, the middleware `requireSession` made. It runs on the server before any loader or action nested in it, which loaders on their own can't promise. Signed-out users go to `/login`, with `?redirectTo=` set to the page they asked for: ```tsx title="app/routes/protected.tsx" import { Outlet } from "react-router"; import { signedIn } from "../auth/session.server"; export const middleware = [signedIn]; // So navigation in the browser runs the middleware. export const loader = () => null; export default function Protected() { return ; } ``` A loader nested in it gets the session from `signedIn.session(context)`, which is never `null` there. Components read the user with the hooks as usual: ```tsx title="app/routes/home.tsx" import { useLoaderData, type LoaderFunctionArgs } from "react-router"; import { signedIn } from "../auth/session.server"; export function loader({ context }: LoaderFunctionArgs) { return { name: signedIn.session(context).user.firstName }; } export default function Home() { const { name } = useLoaderData(); return

Welcome, {name}

; } ``` In `app/routes.ts`, nest the routes it protects: `layout("routes/protected.tsx", [index("routes/home.tsx")])`. During navigation in the browser, server middleware runs only when a route has a loader to fetch, so the layout has one in case the routes nested in it don't. In this guide, the server signs in with your backend, so the user in the cookie comes from the backend. If the browser signs in with the backend itself, have the action take the tokens instead, as [Hybrid setup](https://session-kit.pages.dev/docs/server-rendering/hybrid) shows for TanStack Start. ## Call your API Call your API with `createAuthFetch` from `@monarcode/session-kit/http`. Given the request's session, it sends the access token, refreshed first when it has expired. After a 401, it renews the token, saves it in the cookie, and sends a `GET` again: ```tsx title="app/routes/orders.tsx" import { redirect, useLoaderData, type LoaderFunctionArgs } from "react-router"; import { loginHref } from "@monarcode/session-kit"; import { createAuthFetch } from "@monarcode/session-kit/http"; import { z } from "zod"; import { auth } from "../auth/session.server"; export async function loader({ context }: LoaderFunctionArgs) { const session = auth(context); // Outside the guarded layout: send the user to sign in, then back here. if (!(await session.getSession())) throw redirect(loginHref("/login", "/orders")); const apiFetch = createAuthFetch(session, { baseUrl: "https://api.example.com/", }); const response = await apiFetch("orders"); if (!response.ok) throw new Error(`Orders failed (${response.status})`); return z.array(z.object({ id: z.number() })).parse(await response.json()); } export default function Orders() { const orders = useLoaderData(); return

{orders.length} orders

; } ``` With an HTTP client of your own, such as ky or axios, see [Other HTTP clients](https://session-kit.pages.dev/docs/client/other-http-clients). A client without a `fetch` option reads the token with `credentials.get()`. To call the API from the browser too, with `createAuthFetch` or effetch's `sessionBearer`, opt in with `clientCredentials: true` in `createServerAuth`. The session route then serves the access token, and the client's `credentials` get it there. The browser keeps the access token in memory, never the refresh token; see [Server sessions](https://session-kit.pages.dev/docs/server-rendering/server-sessions#the-access-token-in-the-browser). The example app does this. ## Things to know - **Signing out ends the session only with `revoke`.** Without it, a copy of the cookie made before sign-out still works: see [Sign-out](https://session-kit.pages.dev/docs/server-rendering/server-sessions#sign-out). - **Refresh in middleware.** The request's session sets cookies until the response leaves `sessionMiddleware`, which first waits for a refresh under way. A loader's deferred data that reads the session later, while the page streams, can't set cookies: an expired token then rejects with `REFRESH_FAILED`, since nothing new can be saved. `sessionMiddleware` refreshes before that. - **One session per request.** `auth(context)` returns the same session for every call with the same `context`, which React Router makes per request. - **Caching.** `sessionMiddleware` leaves a response that sets its own `Cache-Control` as it is. Pass `cacheControl` to send another value, or `{ cacheControl: false }` to leave every response as it is. - **Guards with the same names.** `requireSession` and `redirectIfSignedIn` on this page come from `@monarcode/session-kit/react-router/server`. `@monarcode/session-kit/react-router` exports guards with the same names that take the browser's client, for apps without server rendering: see [React Router](https://session-kit.pages.dev/docs/setups/react-router). - **A `basename`.** Pass it to the guard too, as `requireSession(serverAuth, { basename: "/app" })`. React Router adds it to loaders' and actions' redirects, but not to middleware's, so the guard adds it to its own. The client's `endpoint` includes it as well: `"/app/auth/session"`. - **Tabs.** Signing in or out in one tab reaches the others, which ask the server again and run their loaders again. So does returning to a tab after a while. - **More.** Several server instances, the cookie's size, and plain http during development: see [Server sessions](https://session-kit.pages.dev/docs/server-rendering/server-sessions). ## Next steps - [Server sessions](https://session-kit.pages.dev/docs/server-rendering/server-sessions): the cookie, signing out, and the limits every server-rendered setup shares. - [Using the client](https://session-kit.pages.dev/docs/client): what the browser client can do. - [Effect on the server](https://session-kit.pages.dev/docs/effect#on-the-server): run Effects with a request's session. # Next.js (https://session-kit.pages.dev/docs/setups/next) In this guide, you keep the session in an encrypted cookie with the Next.js App Router. A Server Function signs in, a session route answers the browser, Proxy refreshes the tokens, and Server Components read the session. The first response already shows the signed-in page, so the browser never shows a "Checking session…" state or a flash of signed-out content. Client Components get a small client that mirrors the session, and the [hooks](https://session-kit.pages.dev/docs/getting-started#4-read-the-session-in-react) work with it. [Server rendering](https://session-kit.pages.dev/docs/server-rendering) explains how the pieces fit. A complete app is in [`examples/next-app`](https://github.com/monarcode/session-kit/tree/main/examples/next-app). ## Before you begin - Next.js 16, with the App Router. - A secret of 32 or more random characters to seal the cookie, set as `SESSION_SECRET` on the server. To make one, run `openssl rand -base64 32`. `next build` doesn't need it. ## Installation To install the packages, run the following command: ```npm npm install @monarcode/session-kit next zod ``` ## 1. Create the server engine `createServerAuth` from `@monarcode/session-kit/server` keeps the session in a cookie sealed with your `secret`, and refreshes it with your backend. Create it in a server-only module, so Next refuses to bundle it for the browser: ```ts title="lib/auth/server.ts" import "server-only"; import { createServerAuth } from "@monarcode/session-kit/server"; import { z } from "zod"; const API = "https://api.example.com"; const userSchema = z.object({ id: z.number(), firstName: z.string() }); const tokens = z.object({ accessToken: z.string(), refreshToken: z.string() }); export const serverAuth = createServerAuth({ name: "app", user: userSchema, secret: process.env.SESSION_SECRET!, refresh: async ({ refreshToken, signal }) => { const response = await fetch(`${API}/auth/refresh`, { method: "POST", signal, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refreshToken }), }); // The backend rejected the refresh token: sign out. if (response.status === 401) return null; if (!response.ok) throw new Error(`Refresh failed (${response.status})`); return tokens.parse(await response.json()); }, }); /** Signs in with your backend, from the server. */ export async function login(username: string, password: string) { const response = await fetch(`${API}/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }), }); const body: unknown = await response.json().catch(() => null); if (!response.ok) { // The backend says why, such as wrong credentials, in `message`. const failure = z.object({ message: z.string() }).safeParse(body); throw new Error( failure.success ? failure.data.message : `Sign-in failed (${response.status})`, ); } return { ...tokens.parse(body), user: userSchema.parse(body) }; } ``` [Server sessions](https://session-kit.pages.dev/docs/server-rendering/server-sessions#the-cookie) covers its other options, rotating the secret, signing out, and the limits every setup shares. ## 2. Read the session in Server Components `nextSession` from `@monarcode/session-kit/next` gives your app its session over Next's cookies. Session kit doesn't import `next`, so pass it Next's own `cookies` and `redirect`. Create it in a server-only module too: ```ts title="lib/auth/session.ts" import "server-only"; import { nextSession } from "@monarcode/session-kit/next"; import { cookies } from "next/headers"; import { redirect } from "next/navigation"; import { serverAuth } from "./server"; export const session = nextSession(serverAuth, { cookies, redirect }); ``` In a Server Component, `await session.get()` resolves `{ sessionId, user }` or `null`. It holds no tokens, so it's safe to pass to Client Components. It doesn't set cookies, as Server Components can't. Proxy refreshes first. ## 3. Refresh in Proxy Proxy refreshes access that is about to expire before any page or Server Function reads it. `session.refresh(request, response)` sets the new cookie on the response, and Next passes it on to the Server Components rendering the same request: ```ts title="proxy.ts" import { NextResponse, type NextRequest } from "next/server"; import { session } from "./lib/auth/session"; export function proxy(request: NextRequest) { return session.refresh(request, NextResponse.next()); } export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"], }; ``` If the refresh fails, the page still renders with the cookie as it is. Server Functions are POST requests to the page that calls them, so keep the matcher wide enough to include those pages. ## 4. Add the session route and sign-in The browser's client reads the session and signs out through a session route. `sessionRoute` from `@monarcode/session-kit/next` makes its `GET` and `POST` handlers. Pass it Next's `cookies`: ```ts title="app/api/session/route.ts" import { sessionRoute } from "@monarcode/session-kit/next"; import { cookies } from "next/headers"; import { serverAuth } from "../../../lib/auth/server"; export const { GET, POST } = sessionRoute(serverAuth, { cookies }); ``` It answers your own site's pages only, so another site can't sign the user out: see [Security](https://session-kit.pages.dev/docs/security). Signing in is yours, as it takes your form's input. A Server Function sets the cookie with `session.signIn`: ```ts title="app/actions.ts" "use server"; import { z } from "zod"; import { login } from "../lib/auth/server"; import { session } from "../lib/auth/session"; const credentials = z.object({ username: z.string(), password: z.string() }); export async function signInAction(input: unknown) { const { username, password } = credentials.parse(input); try { const tokens = await login(username, password); return { session: await session.signIn(tokens) }; } catch (error) { return { error: error instanceof Error ? error.message : "Sign-in failed", }; } } ``` `signInAction` returns its failure rather than throwing it, as Next advises for expected errors. In production, Next hides a thrown error's message from the browser. ## 5. Create the browser client `createMirrorAuth` makes the client that Client Components use, from the session the layout read, the session route and your sign-in: ```ts title="lib/auth/client.ts" import { createMirrorAuth, type SessionOf } from "@monarcode/session-kit"; import { signInAction } from "../../app/actions"; // Types only: left out of the browser's bundle. import type { serverAuth } from "./server"; export type AppSession = SessionOf; export function createAppAuth(session: AppSession | null) { return createMirrorAuth({ name: "app", session, endpoint: "/api/session", signIn: async (input: { username: string; password: string }) => { const result = await signInAction(input); if ("error" in result) throw new Error(result.error); return result.session; }, }); } export type AppAuth = ReturnType; ``` `useMirrorAuth` from `@monarcode/session-kit/react` creates the client in a Client Component, once per request on the server, never at module level. It keeps the client and the server in step both ways. When the session changes in the browser, it calls `onChange`, here `router.refresh()`, which renders Server Components again. Register the client's type where you render `AuthProvider`, so the hooks carry your user: ```tsx title="app/providers.tsx" "use client"; import type { ReactNode } from "react"; import { AuthProvider, useMirrorAuth } from "@monarcode/session-kit/react"; import { useRouter } from "next/navigation"; import { createAppAuth, type AppAuth, type AppSession, } from "../lib/auth/client"; // Types `AuthProvider` and the hooks with this client. declare module "@monarcode/session-kit" { interface Register { auth: AppAuth; } } export function Providers(props: { session: AppSession | null; children: ReactNode; }) { const router = useRouter(); const auth = useMirrorAuth({ create: createAppAuth, session: props.session, onChange: () => router.refresh(), }); return {props.children}; } ``` Render the providers in the root layout, with the session it reads: ```tsx title="app/layout.tsx" import type { ReactNode } from "react"; import { session } from "../lib/auth/session"; import { Providers } from "./providers"; export default async function RootLayout(props: { children: ReactNode }) { const current = await session.get(); return ( {props.children} ); } ``` ## 6. Protect pages Check the session in each page with `session.require`, rather than in a layout. Layouts don't render again on navigation. It takes the page's own path, where the sign-in page sends the user back, as Server Components don't know their URL: ```tsx title="app/page.tsx" import { session } from "../lib/auth/session"; export default async function Home() { const { user } = await session.require("/"); return

Welcome, {user.firstName}

; } ``` The sign-in page sends a signed-in user on to `redirectTo` with `session.redirectIfSignedIn`, and its form calls the client's `signIn`. When the session changes in the browser, after a sign-in or sign-out here or in another tab, `useMirrorAuth` renders the page again on the server, so these redirects run without code in the form: ```tsx title="app/login/page.tsx" import { session } from "../../lib/auth/session"; import { SignInForm } from "./sign-in-form"; export default async function Login({ searchParams, }: { searchParams: Promise>; }) { await session.redirectIfSignedIn((await searchParams).redirectTo); return ; } ``` The form is a Client Component that calls the client's `signIn`: ```tsx title="app/login/sign-in-form.tsx" "use client"; import { useState, type FormEvent } from "react"; import { useAuthClient } from "@monarcode/session-kit/react"; export function SignInForm() { const auth = useAuthClient(); const [error, setError] = useState(""); async function submit(event: FormEvent) { event.preventDefault(); const form = new FormData(event.currentTarget); try { await auth.signIn({ username: String(form.get("username")), password: String(form.get("password")), }); } catch (failure) { // What the client's signIn option threw, from signInAction's error. setError(failure instanceof Error ? failure.message : "Sign-in failed"); } } return (
{error &&

{error}

}
); } ``` In this guide, the server signs in with your backend, so the user in the cookie comes from the backend. If the browser signs in with the backend itself, have `signInAction` take the tokens instead, as [Hybrid setup](https://session-kit.pages.dev/docs/server-rendering/hybrid) shows for TanStack Start. ## Call your API Call your API with `createAuthFetch` from `@monarcode/session-kit/http`. It sends the request's access token to your API's origin only. In Server Components, pass it `session.readOnly()`, the request's session that doesn't set cookies. Proxy refreshed the token before the render. A token that expired anyway rejects with `REFRESH_FAILED`, and so does a 401 from your API, since nothing new can be saved: ```tsx title="app/orders/page.tsx" import { createAuthFetch } from "@monarcode/session-kit/http"; import { z } from "zod"; import { session } from "../../lib/auth/session"; export default async function Orders() { await session.require("/orders"); const apiFetch = createAuthFetch(await session.readOnly(), { baseUrl: "https://api.example.com/", }); const response = await apiFetch("orders"); if (!response.ok) throw new Error(`Orders failed (${response.status})`); const orders = z .array(z.object({ id: z.number() })) .parse(await response.json()); return

{orders.length} orders

; } ``` In Server Functions and Route Handlers, pass `await session.writable()` instead, the request's session that can set cookies. After a 401, it renews the token, saves it in the cookie, and sends a `GET` again. With an HTTP client of your own, such as ky or axios, see [Other HTTP clients](https://session-kit.pages.dev/docs/client/other-http-clients). A client without a `fetch` option reads the token with `credentials.get()`. To call the API from the browser too, with `createAuthFetch` or effetch's `sessionBearer`, opt in with `clientCredentials: true` in `createServerAuth`. The session route then serves the access token, and the client's `credentials` get it there. The browser keeps the access token in memory, never the refresh token; see [Server sessions](https://session-kit.pages.dev/docs/server-rendering/server-sessions#the-access-token-in-the-browser). The example app does this. ## Things to know - **Signing out ends the session only with `revoke`.** Without it, a copy of the cookie made before sign-out still works: see [Sign-out](https://session-kit.pages.dev/docs/server-rendering/server-sessions#sign-out). - **Writes only where Next allows them.** Use `signIn`, `signOut`, `updateUser` and `writable()` in Server Functions and Route Handlers only. In a Server Component they can't set the cookie, and `writable()` would refresh and then fail to save the new tokens, which spends a rotating refresh token. - **One read per request.** Calls to `session` during one request read the cookie once, and a sign-in through `signIn` shows in `get()`. - **Separate instances.** Next builds Proxy, Route Handlers, and pages with their Server Functions separately, so each has its own `serverAuth`, even on one server. They share no refresh. A matcher that covers every route that reads the session lets Proxy refresh first, so Route Handlers and Server Functions rarely need to. - **A `basePath`.** Next doesn't add it to `fetch`, so the client's `endpoint` includes it: `"/app/api/session"`. Nor does it add it to a Route Handler's redirects, so a Route Handler reads the session with `get` and answers 401, rather than calling `require`. - **Rendering.** Reading cookies makes a page dynamic, and the root layout reads the session, so every page is dynamic. Next sends such pages with `Cache-Control: no-store`, and plain `use cache` can't read cookies. Keep pages that read the session on the Node.js runtime, the default. Next may not pass Proxy's refreshed cookie on to Edge runtime pages. - **Tabs.** Signing in or out in one tab reaches the others, which ask the server again and render their pages again. So does returning to a tab after a while. - **More.** Several server instances, the cookie's size, and plain http during development: see [Server sessions](https://session-kit.pages.dev/docs/server-rendering/server-sessions). ## Next steps - [Server sessions](https://session-kit.pages.dev/docs/server-rendering/server-sessions): the cookie, signing out, and the limits every server-rendered setup shares. - [Using the client](https://session-kit.pages.dev/docs/client): what the browser client can do. - [Effect on the server](https://session-kit.pages.dev/docs/effect#on-the-server): run Effects with a request's session. # Expo Router (https://session-kit.pages.dev/docs/setups/expo) In this guide, you build an Expo Router app for iOS and Android that keeps the session in the device's secure storage, holds the splash screen while it restores, and keeps deep links across sign-in. Expo Router's `Stack.Protected` guards the screens, and `SessionGate` wraps the root navigator. It holds the splash screen while the session is restored, renders `unavailable` after a failed refresh, and checks the session again when the app returns to the foreground. This page builds the [`expo-router` example app](https://github.com/monarcode/session-kit/tree/main/examples/expo-router) from scratch. It signs in against [DummyJSON's auth API](https://dummyjson.com/docs/auth), so it runs as written. Its `src/auth.ts` takes the place of Getting started's `backend.ts` and `auth.ts`. [Using the client](https://session-kit.pages.dev/docs/client), [Refresh](https://session-kit.pages.dev/docs/configuration/refresh) and [Errors](https://session-kit.pages.dev/docs/configuration/errors) explain how the client works. This page covers what differs on Expo. ## Before you begin - Expo SDK 53 or newer: Expo Router 5 or newer, and React Native 0.79 or newer. - iOS and Android only. Expo web is not supported. On the web, use a [web router setup](https://session-kit.pages.dev/docs/installation#pick-your-setup) with `webStorage()`. - Expo Go, which includes `expo-secure-store` and `expo-file-system`, or a development build with both installed. ## Installation Install the package, its Expo peers, and a schema library. `npx expo install` picks the peer versions that match your SDK: ```sh npx expo install @monarcode/session-kit expo-secure-store expo-file-system zod ``` An Expo Router app already has `expo-router` and `react-native`. Holding the splash screen needs `expo-splash-screen`, so install it too if your app doesn't have it. Add the `expo-secure-store` config plugin to `app.json`, so Android Auto Backup leaves the session out of backups: ```json { "expo": { "scheme": "myapp", "plugins": ["expo-router", "expo-secure-store"] } } ``` `scheme` is what custom-scheme deep links such as `myapp://profile` use. ## 1. Create the client `secureStorage()` replaces `webStorage()`. The rest of the client is the same as on the web: ```ts title="src/auth.ts" import { createAuth } from "@monarcode/session-kit"; import { secureStorage } from "@monarcode/session-kit/expo"; import { createAuthFetch } from "@monarcode/session-kit/http"; import { z } from "zod"; const API = "https://dummyjson.com"; export const userSchema = z.object({ id: z.number(), username: z.string(), email: z.string(), firstName: z.string(), }); const tokensSchema = z.object({ accessToken: z.string(), refreshToken: z.string(), }); const loginResponseSchema = tokensSchema.extend(userSchema.shape); export const auth = createAuth({ name: "my-app", user: userSchema, storage: secureStorage(), refresh: async ({ refreshToken, signal }) => { const response = await fetch(`${API}/auth/refresh`, { method: "POST", signal, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refreshToken }), }); // DummyJSON answers 401 or 403 when the refresh token is no good. if (response.status === 401 || response.status === 403) return null; if (!response.ok) throw new Error(`Refresh failed (${response.status})`); return tokensSchema.parse(await response.json()); }, }); export const authFetch = createAuthFetch(auth, { baseUrl: `${API}/` }); export async function signInWithPassword(username: string, password: string) { const response = await fetch(`${API}/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }), }); if (!response.ok) throw new Error(`Sign-in failed (${response.status})`); const { accessToken, refreshToken, ...user } = loginResponseSchema.parse( await response.json(), ); await auth.signIn({ accessToken, refreshToken, user }); } ``` ## 2. Gate the root layout `SessionGate` goes inside `AuthProvider`, around the root navigator. Guard the screens with `Stack.Protected`. Signed-in users see the `(app)` group, and everyone else sees `login`. Register the client's type here, so the hooks from `@monarcode/session-kit/react` carry your user: ```tsx title="app/_layout.tsx" import { isSignedIn } from "@monarcode/session-kit"; import { SessionGate } from "@monarcode/session-kit/expo"; import { AuthProvider, useAuth } from "@monarcode/session-kit/react"; import { SplashScreen, Stack } from "expo-router"; import { auth } from "../src/auth"; import { Unavailable } from "../src/unavailable"; // Types `AuthProvider` and the hooks with this client. declare module "@monarcode/session-kit" { interface Register { auth: typeof auth; } } // Keeps the splash screen up until SessionGate hides it. void SplashScreen.preventAutoHideAsync(); export default function RootLayout() { return ( }> ); } function RootStack() { // Not signed in while locked, though a locked session keeps its user. const signedIn = useAuth(isSignedIn); return ( ); } ``` `RootStack` is its own component so that `useAuth` runs inside `AuthProvider`. The navigator mounts only once the session is restored, so the guards never see `initializing`. If the first screen needs more than the session, such as fonts, pass `ready`. The splash screen then stays up until both are done. Count a failed load as done, or the splash screen never hides: ```tsx const [fontsLoaded, fontError] = useFonts(fonts); } > ; ``` ## 3. Keep deep links `Stack.Protected` sends a link to a guarded screen to the first screen the user may see, so a signed-out user who opens `myapp://profile` lands on the sign-in screen and the link is lost. `deepLinkHandler` keeps it, and `SessionGate` opens it after the sign-in. Expo Router calls `redirectSystemPath` with every URL that opens the app, before routing: ```tsx title="app/+native-intent.tsx" import { deepLinkHandler } from "@monarcode/session-kit/expo"; import { auth } from "../src/auth"; export const redirectSystemPath = deepLinkHandler(auth); ``` It returns the path unchanged. The link still goes to Expo Router, which shows it right away when the user is signed in. If your `redirectSystemPath` does more, such as rewriting old links, call `rememberDeepLink(auth, path)` in it instead. ## 4. Add the sign-in screen The sign-in screen calls `signInWithPassword` from `src/auth.ts`: ```tsx title="app/login.tsx" import { useState } from "react"; import { Button, Text, TextInput, View } from "react-native"; import { signInWithPassword } from "../src/auth"; export default function Login() { // DummyJSON's documented test user. const [username, setUsername] = useState("emilys"); const [password, setPassword] = useState("emilyspass"); const [error, setError] = useState(""); async function submit() { setError(""); try { await signInWithPassword(username, password); // No navigation: Stack.Protected leaves this screen, and // SessionGate opens the deep link the user was headed to. } catch (cause) { setError(cause instanceof Error ? cause.message : "Could not sign in"); } } return ( {error ? {error} : null} {count !== null &&

{count} orders

} ); } ``` With ky, axios or openapi-fetch, pass `apiFetch` as the client's `fetch`: see [Other HTTP clients](https://session-kit.pages.dev/docs/client/other-http-clients). The browser keeps the access token in memory, never the refresh token. After a 401, the client asks the session route for a new token, and the server refreshes with the cookie's refresh token. Server functions can still call your API too, as the guide's [Call your API](https://session-kit.pages.dev/docs/setups/tanstack-start#call-your-api) shows. ## Things to know - **The refresh token passes through the browser once,** in your backend's sign-in response, on its way to the server. After that, page scripts never see it. If your backend's sign-in can run on the server, sign in there, as the setup guides do. - **Your backend must allow your site (CORS)** for its sign-in and the API calls you make from the browser. Session kit's own requests go to your session route, on your site. - **Signing out** works as in the guide's [Sign out](https://session-kit.pages.dev/docs/setups/tanstack-start#sign-out), through the session route. To end the session at your backend too, give `createServerAuth` a `revoke`. - **Security** has more on what the cookie's user can be trusted for, in [Your backend decides what's allowed](https://session-kit.pages.dev/docs/security#your-backend-decides-whats-allowed). # Using the client (https://session-kit.pages.dev/docs/client) This section is for code outside React components. Components read the session with hooks. Everything else, such as loaders, event handlers, and request code, calls the client that `createAuth` returned. These are its methods: | Call | What it does | | ------------------------------------------------- | --------------------------------------------------------------------------------------------- | | `auth.state.get()`, `.subscribe()` | Reads the [state](https://session-kit.pages.dev/docs/client/state), or follows its changes. | | `auth.getSession()` | Resolves who is signed in, restoring or refreshing first, or `null`. | | `auth.signIn({ ...tokens, user })` | Saves a new session, replacing any current one. | | `auth.signOut()` | Ends the session, clears storage, and tells other tabs. | | `auth.updateUser(user)` | Replaces the user and keeps the session. | | `auth.refresh()` | Refreshes now, even while the access token is usable. | | `auth.retry()` | Tries again after a failed refresh. | | `auth.isCurrent(session)` | Tells whether a session is still the signed-in one. | | `auth.credentials.get()`, `.renew()`, `.reject()` | Resolves the access token, and replaces or ends it after a 401, for code that sends requests. | | `auth.lock()`, `auth.unlock()` | Locks and unlocks a saved session, with [`locking`](https://session-kit.pages.dev/docs/client/locking). | | `auth.mount()` | Starts timers and tab sync. `AuthProvider` and the routers call it. | Each method's JSDoc says what it resolves and which `AuthError` it rejects with, so your editor shows the details where you use them. With server rendering, the browser's client comes from `createMirrorAuth`. It has the same state, `getSession`, `signOut`, `isCurrent` and `credentials`, but no `lock`, `unlock` or `updateUser`. See [Server rendering](https://session-kit.pages.dev/docs/server-rendering). The examples in this section build on the app from [Getting started](https://session-kit.pages.dev/docs/getting-started). `auth` is its client, and `authFetch` its `fetch`. - [State](https://session-kit.pages.dev/docs/client/state): Read the state outside React, and what each field holds. - [Sessions and credentials](https://session-kit.pages.dev/docs/client/sessions): Ask who is signed in, and keep the access token apart from the user. - [Signing in and out](https://session-kit.pages.dev/docs/client/signing-in-and-out): Save a new session, end it, and revoke its tokens. - [Updating the user](https://session-kit.pages.dev/docs/client/updating-the-user): Replace the user without ending the session. - [Authenticated requests](https://session-kit.pages.dev/docs/client/requests): A fetch that sends the access token to one API, and refreshes on a 401. - [Other HTTP clients](https://session-kit.pages.dev/docs/client/other-http-clients): Send the access token with ky, axios, openapi-fetch or any other client. - [Users from the access token](https://session-kit.pages.dev/docs/client/users-from-tokens): Read the user from a JWT's claims instead of the sign-in response. - [Locking](https://session-kit.pages.dev/docs/client/locking): Ask a returning user to unlock, for example with biometrics, in native apps. # State (https://session-kit.pages.dev/docs/client/state) `auth.state` holds what `useAuth` reads in components. Outside React, read it once with `get`, or follow it with `subscribe`: ```ts title="src/status-log.ts" import { auth } from "./auth.js"; /** Logs each change of status. Returns a function that stops it. */ export function logStatus() { let last = auth.state.get().status; const { unsubscribe } = auth.state.subscribe(({ status }) => { if (status === last) return; last = status; console.info(`Auth is ${status}`); }); return unsubscribe; } ``` `subscribe` reports later changes, not the current value. A listener that throws doesn't interrupt auth. Its error is rethrown asynchronously, where error reporting can catch it. The state holds these fields: | Field | Holds | | ----------- | ------------------------------------------------------------------------------------- | | `status` | Where the session stands. [How it works](https://session-kit.pages.dev/docs#how-it-works) lists each one. | | `user` | The signed-in user, or `null`. Checking `status` narrows it. | | `sessionId` | New on every sign-in, and stable across refreshes and profile updates. | | `error` | The latest failure, as an [`AuthError`](https://session-kit.pages.dev/docs/configuration/errors), or `null`. | | `version` | Changes when guards must run again. `connectAuth` and `useAuthRevalidation` watch it. | Two habits follow from these fields: - **Scope private cached data to `sessionId`**, such as TanStack Query results, so one account never sees another's. - **Check `status`, not `user`, to know whether access is usable.** A [locked](https://session-kit.pages.dev/docs/client/locking) session keeps its user. `isSignedIn(state)` is the check. It's `true` while `authenticated` or `refreshing`, and it narrows `user`. `useAuth(isSignedIn)` reads it in a component. # Sessions and credentials (https://session-kit.pages.dev/docs/client/sessions) A **session** is who is signed in, `{ sessionId, user }`, and never holds tokens. `getSession()` is how code outside React asks for it. It waits for a restore or a refresh that is under way, so a loader never acts on a stale answer: ```ts title="src/cart.ts" import { authFetch } from "./api.js"; import { auth } from "./auth.js"; /** A loader: it runs outside React, so it asks the client. */ export async function loadCart(): Promise { const session = await auth.getSession(); if (!session) return null; const response = await authFetch(`auth/carts/user/${session.user.id}`); if (!response.ok) throw new Error(`Cart failed (${response.status})`); return response.json(); } ``` **Credentials** are the access token, `{ sessionId, accessToken, expiresAt }`, kept apart so that code which only needs the user never holds one. [`createAuthFetch`](https://session-kit.pages.dev/docs/client/requests) reads them for you, and so does an HTTP client that takes `authFetch` as its `fetch`. Code that sends requests another way uses `auth.credentials`, as [Other HTTP clients](https://session-kit.pages.dev/docs/client/other-http-clients#clients-without-a-fetch-option) shows. `auth.isCurrent({ sessionId })` tells whether a session you're holding is still the signed-in one, for work that might finish after an account switch. # Signing in and out (https://session-kit.pages.dev/docs/client/signing-in-and-out) `auth.signIn({ ...tokens, user })` validates and saves a new session, replacing any current one, even a locked one. With [`fromAccessToken`](https://session-kit.pages.dev/docs/client/users-from-tokens), leave out `user`. The latest call wins, and invalid input keeps the previous session. [Expiry](https://session-kit.pages.dev/docs/configuration/expiry) covers how the token's lifetime is found. `auth.signOut()` ends the session and clears storage. Other tabs sign out too when the storage reports changes, as `webStorage()` does, and `locking` is off. If your backend can revoke tokens, give the client a `revoke` callback. It receives the ended tokens after the user is signed out: ```ts title="src/revoke.ts" import type { RevokeFn } from "@monarcode/session-kit"; /** Pass as `createAuth`'s `revoke` option. */ export const revoke: RevokeFn = async ({ refreshToken, signal }) => { if (!refreshToken) return; const response = await fetch("https://api.example.com/auth/revoke", { method: "POST", signal, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refreshToken }), }); if (!response.ok) throw new Error(`Revoke failed (${response.status})`); }; ``` `signOut()` resolves once `revoke` settles, so await it before a full-page redirect, which would cancel the request. A failed revocation shows in `state.error` as `REVOKE_FAILED`, and the sign-out stands. The user asked to leave, and has. The client doesn't revoke a session the backend already rejected. # Updating the user (https://session-kit.pages.dev/docs/client/updating-the-user) `auth.updateUser` replaces the user and keeps the session. When you already have the new user, such as from a form's response, pass it directly: `auth.updateUser(user)`. When the new user comes from a request, pass a function that fetches it instead. The client notes the session before the function runs. If the session changes meanwhile, `updateUser` rejects with `SESSION_CHANGED`, and the user it fetched never lands on another account. `fetchProfile` is the request from [Getting started](https://session-kit.pages.dev/docs/getting-started#5-call-your-api): ```ts title="src/profile.ts" import { fetchProfile } from "./api.js"; import { auth } from "./auth.js"; export const reloadProfile = () => auth.updateUser(fetchProfile); ``` # Authenticated requests (https://session-kit.pages.dev/docs/client/requests) `createAuthFetch(auth, { baseUrl })` returns a `fetch` for one API. It's built so that a token can't leak and a user can't be signed out by accident: | When… | `authFetch`… | | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | | The request is for another origin, or has a username or password in its URL | Rejects with `INVALID_OPTIONS`. The token goes only to `baseUrl`'s origin. | | The response is a redirect | Rejects rather than following it. | | The response is a 401 | Refreshes once, then sends a GET or HEAD again with the new token. | | Any other method gets a 401 | Refreshes, and resolves the 401. It never sends a write twice on its own. | | The replayed request gets a 401 too | Signs out, unless `signOutOnRepeated401` is `false`. | | The session changes during the request | Rejects with `SESSION_CHANGED` rather than resolve another account's answer. | | Nobody is signed in | Rejects with `UNAUTHENTICATED`, unless `optional` is `true`. | | The session is locked | Rejects with `LOCKED`, even when `optional` is `true`. | | A refresh fails | Rejects with that failure, such as `REFRESH_FAILED`. | | The network fails | Rejects as `fetch` does, unchanged. | Call it with a path, which is resolved against `baseUrl` as a link is against a page's address. End `baseUrl` with `/`, so that `authFetch("orders")` lands under it. It also takes a `URL` or a `Request`, as `fetch` does, so an HTTP client with a `fetch` option can send its requests through it. See [Other HTTP clients](https://session-kit.pages.dev/docs/client/other-http-clients). A `Request` with a body can be sent once, as with `fetch`. To retry a write after a 401, send a clone you kept. Because a write is never replayed for you, retry it yourself where sending it twice is safe. By then the token has been refreshed: ```ts title="src/add-to-cart.ts" import { authFetch } from "./api.js"; export async function addToCart(userId: number, productId: number) { const send = () => authFetch("auth/carts/add", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ userId, products: [{ id: productId, quantity: 1 }], }), }); let response = await send(); // The 401 refreshed the token, so one more try uses the new one. if (response.status === 401) response = await send(); if (!response.ok) throw new Error(`Add to cart failed (${response.status})`); } ``` Two more options change the defaults: ```ts title="src/public-api.ts" import { createAuthFetch } from "@monarcode/session-kit/http"; import { auth } from "./auth.js"; import { API } from "./backend.js"; export const publicFetch = createAuthFetch(auth, { baseUrl: `${API}/`, // One `fetch` for public and private endpoints: signed out, requests are // sent without a token instead of rejecting. optional: true, // For APIs that answer 401 for reasons other than a rejected token, so a // second 401 from one of those endpoints doesn't sign users out. signOutOnRepeated401: false, }); ``` Without a refresh token or a `refresh` option, there's nothing to refresh with, so the first 401 signs the user out, whatever `signOutOnRepeated401` is. # Other HTTP clients (https://session-kit.pages.dev/docs/client/other-http-clients) [`authFetch`](https://session-kit.pages.dev/docs/client/requests) takes a `URL` or a `Request` as well as a path, as `fetch` does. An HTTP client that lets you choose its `fetch` can send its requests through it, and they get everything `authFetch` does: the token stays on your API's origin, a 401 refreshes once, and only a GET or HEAD is sent again. A client with its own transport uses [the calls `authFetch` is built on](#clients-without-a-fetch-option) instead. ## Clients that take a `fetch` With [ky](https://github.com/sindresorhus/ky), pass `authFetch` as `fetch`: ```ts title="src/ky.ts" import ky from "ky"; import { authFetch } from "./api.js"; import { API } from "./backend.js"; export const api = ky.create({ baseUrl: `${API}/`, fetch: authFetch }); export async function fetchCarts(userId: number): Promise { return api.get(`auth/carts/user/${userId}`).json(); } ``` With [axios](https://axios-http.com), choose its `fetch` adapter and pass `authFetch` in `env`: ```ts title="src/axios.ts" import axios from "axios"; import { authFetch } from "./api.js"; import { API } from "./backend.js"; export const http = axios.create({ baseURL: API, adapter: "fetch", env: { fetch: authFetch }, }); ``` [openapi-fetch](https://openapi-ts.dev/openapi-fetch/) takes it the same way as ky: ```ts const client = createClient({ baseUrl: API, fetch: authFetch }); ``` What changes for the client's calls: - **Give the client the same API.** Its base URL must be on the origin that `authFetch` was created for. A request for another origin rejects with `INVALID_OPTIONS`, and the token isn't sent. - **Signed out, every call rejects** with `UNAUTHENTICATED`. If the client also calls public endpoints, such as your sign-in, create `authFetch` with [`optional: true`](https://session-kit.pages.dev/docs/client/requests). Those requests then go without a token. - **A 401 reaches the client after the refresh.** For a GET or HEAD, the client sees the answer to the request that was sent again. For a write, it sees the 401, as ky's `HTTPError` or axios's `AxiosError`. Retry it where sending it twice is safe. - **Session kit's errors keep their code.** ky rejects with the `AuthError` itself. axios wraps it in an `AxiosError` that has the same `code`, with the `AuthError` as its `cause`. Match on `error.code`, which works with both, rather than on `instanceof AuthError`. - **`authFetch` sets `Authorization`.** While signed in, it replaces a header the client set itself. - **Only what a `Request` holds is sent.** ky hands `fetch` the options it doesn't know itself, such as Next.js's `next`. `authFetch` doesn't pass those on. ## Clients without a `fetch` option `createAuthFetch` and the [effetch plugin](https://session-kit.pages.dev/docs/effect#6-call-your-api-with-effetch) are built on three calls, and a client with its own transport can use the same ones: 1. **Before a request**, `auth.credentials.get()` resolves the credentials to send, waiting for a restore or a refresh that is under way. It resolves `null` when nobody is signed in. 2. **After a 401**, `auth.credentials.renew(credentials)` takes the credentials that request was sent with, and resolves new ones. However many requests failed, they share one refresh. It resolves `null` when the user was signed out instead. 3. **After a second 401**, `auth.credentials.reject(credentials)` ends the session, unless newer credentials have replaced the ones you pass. An axios instance that keeps its default adapter does it with two interceptors: ```ts title="src/axios-interceptors.ts" import type { Credentials } from "@monarcode/session-kit"; import axios, { type InternalAxiosRequestConfig } from "axios"; import { auth } from "./auth.js"; import { API } from "./backend.js"; type Config = InternalAxiosRequestConfig & { replayed?: boolean }; // One instance for one API, so the token goes nowhere else. export const http = axios.create({ baseURL: API }); /** The credentials each request was sent with. */ const sent = new WeakMap(); http.interceptors.request.use(async (config) => { const credentials = await auth.credentials.get(); // Signed out, the request goes without a token, as a public one would. if (credentials) { config.headers.set("Authorization", `Bearer ${credentials.accessToken}`); sent.set(config, credentials); } return config; }); http.interceptors.response.use(undefined, async (error: unknown) => { if (!axios.isAxiosError(error) || error.response?.status !== 401) throw error; const config: Config | undefined = error.config; const used = config && sent.get(config); if (!config || !used) throw error; if (config.replayed) { // The new token was refused too: end the session. await auth.credentials.reject(used); throw error; } const fresh = await auth.credentials.renew(used); // As with authFetch, only a GET or HEAD is sent twice. For anything else // the caller decides. if (!fresh || !["get", "head"].includes(config.method ?? "")) throw error; const replay: Config = { ...config, replayed: true }; return http.request(replay); }); ``` Three things `createAuthFetch` does for you are yours to keep here: - **Keep the token on one origin.** Give the client one base URL, and don't pass it absolute URLs to other hosts. - **Don't read the token early.** Ask for credentials in the request hook, never once at startup. A token saved in a variable goes stale at the next refresh. - **Check the account after slow work.** `auth.isCurrent(credentials)` tells whether the session that sent a request is still the signed-in one, before you show its answer. # Users from the access token (https://session-kit.pages.dev/docs/client/users-from-tokens) If your backend puts the user in a JWT access token, read it from there with `fromAccessToken`. `signIn` then takes tokens only, the refresh callback returns them, and the user changes whenever the token does: ```ts title="src/token-auth.ts" import { createAuth, fromAccessToken, webStorage, } from "@monarcode/session-kit"; import { z } from "zod"; const claimsSchema = z.object({ sub: z.string(), email: z.string() }); export const tokenAuth = createAuth({ name: "my-token-app", // The claims are validated, then mapped to the user the app sees. user: fromAccessToken(claimsSchema, (claims) => ({ id: claims.sub, email: claims.email, })), storage: webStorage(), }); export const signInWithTokens = (tokens: { accessToken: string; refreshToken: string; }) => tokenAuth.signIn(tokens); ``` Reading the user from the token changes a few things: - **No `updateUser`.** The token is the only source of the user, so call `auth.refresh()` to pick up a change. - **The signature isn't verified.** That suits a browser, which trusts a user object in a JSON response to the same degree. Pass `{ decode }` as the third argument to verify or decode the token yourself. - **Leave out claims that change on every refresh**, such as `exp`, `iat`, or `jti`, so the user stays the same object across refreshes. - **For backends you control.** OAuth providers can change their access token format without notice. The [`tanstack-start-spa`](https://github.com/monarcode/session-kit/tree/main/examples/tanstack-start-spa) and [`react-router-data`](https://github.com/monarcode/session-kit/tree/main/examples/react-router-data) examples read their user this way. # Locking (https://session-kit.pages.dev/docs/client/locking) Locking is for native apps that ask a returning user to unlock, for example with biometrics, before the app opens. With the `locking` option, a saved session restores as `locked`. `user` and `sessionId` are set, so a "Welcome back" screen can greet the user, but the tokens wait for `auth.unlock()`. The following client encrypts its saved tokens with a key the app holds: ```ts title="src/locked-auth.ts" import { createAuth, type AuthStorage } from "@monarcode/session-kit"; import { userSchema } from "./backend.js"; // Your app's own: a native secure store, and encryption with a key it holds // in memory only while unlocked. declare const storage: AuthStorage; declare const key: CryptoKey; declare function encrypt(key: CryptoKey, plain: string): Promise; declare function decrypt(key: CryptoKey, sealed: string): Promise; export const lockedAuth = createAuth({ name: "my-native-app", user: userSchema, storage, locking: { // Runs whenever the tokens are saved. seal: (payload) => encrypt(key, payload), // Runs only from `unlock()`. open: (sealed) => decrypt(key, sealed), }, }); /** Once the app has its key: resolves the session, now `authenticated`. */ export const unlockApp = () => lockedAuth.unlock(); /** `locked` again. The saved session stays, and the tokens leave memory. */ export const lockApp = () => lockedAuth.lock(); ``` Locking comes with these rules: - **Sealing is yours.** Session kit depends on no crypto or biometrics library. The user and session ID are saved beside the sealed tokens, readable while locked. `locking: true` stores the tokens as they are, for a lock screen in front of an otherwise ordinary session. - **While locked, nothing waits.** `getSession`, `retry`, `refresh`, `updateUser`, `credentials.get`, and `credentials.renew` reject with `LOCKED` instead of waiting for an unlock that may never come. Nothing refreshes, and timers are off. `signIn` still works and replaces the locked session, so a user who can't unlock can sign in again. After `lock()` resolves, `signOut` clears it without calling `revoke`, since no tokens are left in memory. - **A failed unlock isn't always still locked.** When `open` throws, `unlock()` rejects with `UNLOCK_FAILED` and the session stays locked. But a refresh that fails after the tokens were opened rejects with `REFRESH_FAILED` and leaves the session unlocked and `unavailable` until `retry()`. Check `status` after a rejection. - **Keep the key until `lock()` settles.** A refresh already running still saves its tokens, and saving seals them. - **Native apps only.** Locking is built for one JavaScript context. In a browser, tabs don't follow each other with `locking`, and the router adapters treat a locked session as signed out. It also needs a user schema: a user read from the access token can't be shown without its token. - **Turning it on and off.** A session saved before `locking` was added restores as `locked` too, and is sealed when unlocked. Turning `locking` off later makes sealed sessions unreadable, which signs those users out. ## Next steps The [Expo guide](https://session-kit.pages.dev/docs/setups/expo#locking) has a worked example: a key behind the device's biometrics, AES-GCM sealing, and a welcome-back screen. # Configuring the client (https://session-kit.pages.dev/docs/configuration) `createAuth` builds the client from a few options. This section explains each one, for when the defaults in [Getting started](https://session-kit.pages.dev/docs/getting-started) no longer fit your app. ## Options `createAuth` takes three required options and five optional ones: | Option | Required | What it sets | | --------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `name` | Yes | Names the saved session and the refresh lock: letters, digits, `_` or `-`, up to 64 characters. Keeps two apps on one origin apart. | | `user` | Yes | A [user schema](https://session-kit.pages.dev/docs/configuration/user-schemas), or [`fromAccessToken`](https://session-kit.pages.dev/docs/client/users-from-tokens). | | `storage` | Yes | [Where the session is saved](https://session-kit.pages.dev/docs/configuration/storage). | | `refresh` | No | [How to get new tokens](https://session-kit.pages.dev/docs/configuration/refresh). Without it, an expired token signs the user out. | | `revoke` | No | [Revokes the ended tokens](https://session-kit.pages.dev/docs/client/signing-in-and-out) on your backend after a sign-out. | | `maxAge` | No | How long a saved session can be restored, in seconds. Default: 30 days, counted again from each save. | | `debug` | No | [Reports what the client does](https://session-kit.pages.dev/docs/configuration/debugging). Default: off. | | `locking` | No | [Restores a saved session as `locked`](https://session-kit.pages.dev/docs/client/locking), for native apps. Default: off. | `createAuth` throws an `AuthError` with the code `INVALID_OPTIONS` for an option it can't use, such as a `name` with other characters, or a `maxAge` that isn't a positive whole number. ## Pages in this section Each page covers one part of the client: how it checks users, gets new tokens, saves the session, follows other tabs, and reports what happens. - [User schemas](https://session-kit.pages.dev/docs/configuration/user-schemas): When the schema checks users, and the rules restoring a session sets for it. - [Refresh](https://session-kit.pages.dev/docs/configuration/refresh): What the refresh callback returns, and when a refresh runs. - [Expiry](https://session-kit.pages.dev/docs/configuration/expiry): How the client finds out when the access token expires. - [Storage](https://session-kit.pages.dev/docs/configuration/storage): Where the session is saved: localStorage, sessionStorage, the device, or your own store. - [Tabs](https://session-kit.pages.dev/docs/configuration/tabs): How tabs follow each other's sign-ins, and share one refresh. - [Errors](https://session-kit.pages.dev/docs/configuration/errors): How errors reach your app, and the failures that need a decision from you. - [Debugging](https://session-kit.pages.dev/docs/configuration/debugging): Log what the client does, or send it to your error reporting. # User schemas (https://session-kit.pages.dev/docs/configuration/user-schemas) The schema you pass as `createAuth`'s `user` option checks each user the client accepts and gives the user its type. This page explains when it runs, and the two rules it must follow so that a saved session can be restored. Any [Standard Schema](https://standardschema.dev) library works, such as Zod, Valibot or ArkType. If your backend puts the user in the access token instead, see [Users from the access token](https://session-kit.pages.dev/docs/client/users-from-tokens). ## When the schema runs The client runs the schema on every user it accepts: - At sign-in. - When `updateUser` replaces the user. - After a refresh that returns a user. - When it restores a saved session. - When another tab saves a session. It keeps a frozen copy of each user it accepts. To change the user, call [`updateUser`](https://session-kit.pages.dev/docs/client/updating-the-user). ## The user's type `createAuth` infers the user's type from the schema's output. Hover `user` to see it: ```ts twoslash import { createAuth, memoryStorage } from "@monarcode/session-kit"; import { z } from "zod"; const auth = createAuth({ name: "my-app", user: z.object({ id: z.number(), firstName: z.string() }), storage: memoryStorage(), }); const { user } = auth.state.get(); // ^? ``` To name the types elsewhere, use `UserOf` and `SessionOf`. To type the hooks from `@monarcode/session-kit/react`, register the client once, as [Getting started](https://session-kit.pages.dev/docs/getting-started#4-read-the-session-in-react) shows. ## Rules for restoring The client saves the user beside the tokens, and runs the schema on it again when it restores the session. That sets two rules for the schema: - **Its output is a plain JSON object.** Dates, `Map`s and class instances can't be saved. - **It accepts its own output and returns it unchanged.** A one-way transform, such as one that renames a field, breaks this rule. A schema that breaks either rule is rejected with `USER_VALIDATION_FAILED` the first time it accepts a user, usually at sign-in. That way the problem shows up at once, not as users being signed out on their next reload. ## Users the schema rejects A deploy can change the schema so that it rejects users saved before it. The client doesn't restore such a user, so the user signs in again. A user saved by another tab that the schema rejects signs every tab out, with `USER_VALIDATION_FAILED` in `state.error`. # Refresh (https://session-kit.pages.dev/docs/configuration/refresh) `createAuth`'s `refresh` option is a callback that exchanges the refresh token for new tokens. This page explains what its results mean and when the client calls it. For a callback to start from, see `refresh` in [Getting started](https://session-kit.pages.dev/docs/getting-started#1-talk-to-your-backend). ## What the callback returns The callback ends in one of three ways, and the client handles each differently: | The callback | What the client does | | -------------- | -------------------------------------------------------------------------------------------------------------------------- | | Returns tokens | Saves them. A refresh token or user it leaves out keeps the current one. | | Returns `null` | Treats the refresh token as rejected: signs the user out and clears storage. | | Throws | Treats it as an outage: keeps the session and reports `REFRESH_FAILED`. See [When a refresh fails](#when-a-refresh-fails). | When the client can tell [when the new access token expires](https://session-kit.pages.dev/docs/configuration/expiry), the token must last at least five seconds. A shorter one is an `INVALID_SESSION` error. Return `null` only when your backend rejected the refresh token, such as with a 401. Throw for anything else, such as a network error or a 500, so that an outage doesn't sign anyone out. To sign the user out on every failure, return `null` instead of throwing. ## When a refresh runs The client refreshes at one of three moments: - **Early.** While the client is mounted, 60 seconds before the access token expires, or halfway through when the token lasts under two minutes. It tries once per token. If that fails, the token stays usable until it expires. - **At expiry, or after a 401.** `status` is `refreshing` while it runs, and the user stays on screen. - **On request.** `auth.refresh()` runs one now. ## When a refresh fails If a refresh the session needed fails, `status` becomes `unavailable`. `getSession()` then rejects with the same error until you call `auth.retry()`, or the user signs in or out. Guards that run again therefore can't start a refresh loop. ## Deadlines The client gives each refresh, and each check of a user, 15 seconds. The callback's `signal` aborts when that time runs out or when the session changes, such as on sign-out. Pass it to `fetch`. # Expiry (https://session-kit.pages.dev/docs/configuration/expiry) The client refreshes the access token before it expires, so it needs to know when that is. This page explains where it reads the expiry from, and which field to send. ## Where the expiry comes from The client takes the expiry from the first of these that your tokens have: 1. `expiresIn`, in seconds as in OAuth, or `expiresAt`, in Unix milliseconds. 2. A JWT access token's `exp` and `iat` claims, as a lifetime. 3. A JWT access token's `exp` claim alone, as a time. Send `expiresIn` when your backend gives a lifetime, as most do. The client counts it from when it receives the tokens, so a wrong device clock doesn't matter. A JWT with `iat` works the same way. Send `expiresAt` only when your backend gives no lifetime. Pass `expiresIn` or `expiresAt`, not both. Tokens with both are an `INVALID_SESSION` error. See [`Tokens`](https://session-kit.pages.dev/docs/reference/core#tokens) for each field. To map an OAuth-style response to `Tokens`, pass `expires_in` as `expiresIn`: ```ts title="src/tokens.ts" import type { Tokens } from "@monarcode/session-kit"; type TokenResponse = { access_token: string; refresh_token: string; expires_in: number; }; export const toTokens = (body: TokenResponse): Tokens => ({ accessToken: body.access_token, refreshToken: body.refresh_token, expiresIn: body.expires_in, }); ``` ## Fixed times and device clocks `expiresAt` and a JWT's `exp` alone are fixed times, which the client compares with the device's clock. Device clocks can be minutes off. A clock that runs fast makes the token look already expired, and `signIn` rejects it with `INVALID_SESSION`. ## Tokens without an expiry When the client can't tell when the token expires, it uses the token until your API rejects it. [`authFetch`](https://session-kit.pages.dev/docs/client/requests) then refreshes after the 401. # Storage (https://session-kit.pages.dev/docs/configuration/storage) `createAuth`'s `storage` option chooses where the client saves the session. This page helps you pick a built-in store, or write your own. ## Built-in stores The `storage` option takes one of Session kit's stores, or any object that implements `AuthStorage`: | Storage | Saved in | Use it for | | --------------------------------- | ----------------------------------------- | ---------------------------------------------------- | | `webStorage()` | `localStorage` | Browsers. One session, shared by every tab. | | `webStorage({ area: "session" })` | `sessionStorage` | One session per tab, ending with the tab. | | `secureStorage()` | The iOS keychain, or encrypted on Android | Expo apps. From `@monarcode/session-kit/expo`. | | `memoryStorage()` | Memory | Tests, and sessions that shouldn't outlive the page. | | Your own `AuthStorage` | Anywhere | Another store. Its operations may return promises. | For Expo, `secureStorage()` splits a session larger than 2048 bytes, which some iOS releases refuse to store in one value, across several keys. See [Secure storage](https://session-kit.pages.dev/docs/setups/expo#secure-storage) in the Expo guide. ## Rotating refresh tokens If your backend rotates refresh tokens, use `localStorage`, not `sessionStorage`. A browser copies `sessionStorage` into a duplicated tab, and into a window opened with `window.open`. Both then start with the same refresh token but never see each other's saves. The second to refresh sends a spent token and is signed out. With reuse detection on the backend, both are. ## Write your own store An `AuthStorage` needs three operations: `get`, `set` and `remove`. Each can return a promise. `set` also receives `expiresAt`: when the saved session stops being valid, `maxAge` after this save, in Unix milliseconds. For example, to save the session in a key-value store your app already has, which removes each entry when it expires: ```ts title="src/storage.ts" import type { AuthStorage } from "@monarcode/session-kit"; declare const store: { get(key: string): Promise; put( key: string, value: string, options: { expiresAt: number }, ): Promise; delete(key: string): Promise; }; export const kvStorage: AuthStorage = { get: async (key) => (await store.get(key)) ?? null, // The store drops the session itself once it's no longer valid. set: (key, value, { expiresAt }) => store.put(key, value, { expiresAt }), remove: (key) => store.delete(key), }; ``` The client runs your store's operations one at a time, in the order it issued them. A store that several tabs share also needs `subscribe` and `lock`, so that [tabs follow each other](https://session-kit.pages.dev/docs/configuration/tabs). See [`AuthStorage`](https://session-kit.pages.dev/docs/reference/core#authstorage) for each member. ## What the client saves The client saves the tokens and the user in one `:auth:session` entry. Each save is then a single write, which other tabs see whole. A [user read from the access token](https://session-kit.pages.dev/docs/client/users-from-tokens) isn't saved. The client reads it from the token again on restore. # Tabs (https://session-kit.pages.dev/docs/configuration/tabs) In a browser, each open tab runs its own client over the same saved session. This page explains how the tabs stay in step, and how they share one refresh. ## Sign-ins and sign-outs While the client is [mounted](https://session-kit.pages.dev/docs/client), each tab follows the others through `storage` events: - A sign-out in one tab signs out every tab. - A sign-in or an account switch switches every tab. - A profile update or a refresh shows in every tab. With server rendering, `createMirrorAuth` clients follow each other over a `BroadcastChannel` instead, as [Server rendering](https://session-kit.pages.dev/docs/server-rendering) explains. ## One refresh at a time The client refreshes under the `:auth:refresh` [Web Lock](https://developer.mozilla.org/docs/Web/API/Web_Locks_API). One tab at a time spends the refresh token, and the others reuse the tokens it saved. Rotating refresh tokens therefore work with several tabs open. Without Web Locks, two tabs that refresh at the same instant can both spend the token. A short reuse window on your backend covers that case. ## Expo apps An Expo app has one JavaScript context, so it has no tabs to follow and needs no lock. In a browser, [`locking`](https://session-kit.pages.dev/docs/client/locking) stops tabs from following each other. # Errors (https://session-kit.pages.dev/docs/configuration/errors) Every error from Session kit's own logic is an `AuthError`, whether a method throws it, rejects with it, or publishes it in `state.error`. This page shows how to match on them, how to turn them into messages for your users, and the storage failures you need to handle. ## Error codes Match on an error's `code`, not its message, which may change in any release. [Error codes](https://session-kit.pages.dev/docs/reference/error-codes) lists every code and what it means. ## Show errors to users An error's `cause` holds whatever failed underneath, such as the error your refresh callback threw. To keep messages out of your components, map codes to your own wording: ```ts title="src/error-message.ts" import { AuthError, type AuthErrorCode } from "@monarcode/session-kit"; const messages: Partial> = { REFRESH_FAILED: "We can't reach the server. Check your connection.", PERSISTENCE_FAILED: "We couldn't save or clear your session.", UNAUTHENTICATED: "Sign in to continue.", }; export function errorMessage(error: unknown): string { if (error instanceof AuthError) return messages[error.code] ?? error.message; return error instanceof Error ? error.message : "Something went wrong"; } ``` `authFetch` passes on the failures of `fetch` and `Request`, such as a network error, unchanged. With server rendering, the browser client's `signIn` passes on what your sign-in function threw, unchanged too. These usually aren't `AuthError`s, so `errorMessage` shows their own message. ## Handle storage failures Three storage failures need a decision from you: - **Storage can't be read.** `status` becomes `unavailable`, with the error in `state.error`. The saved session stays as it is, and the next `getSession()` or `retry()` reads it again. - **Storage can't be written.** The client ends the session and publishes the error. That way, no one stays signed in to a session that would vanish on reload. - **Storage can't be cleared.** Sign-out still takes effect in this tab, but `signOut()` rejects and `state.error` keeps the error. The saved credentials could restore the session on reload, so call `signOut()` again once storage works. # Debugging (https://session-kit.pages.dev/docs/configuration/debugging) `createAuth`'s `debug` option reports what the client does: restores, refreshes and why they ran, timers, storage operations and how long they took, other tabs' changes, state changes, and what ended a session. Use it to find out why a session ended, or why a refresh ran. ## Log to the console To log each event as one `console.info` line, pass `debug: true`. A refresh logs a line like this one: ```text [session-kit:my-app] refresh: started (proactive) { sessionId: "…", reason: "proactive" } ``` ## Send events to your error reporting To receive each event instead of logging it, pass a function. For example, to leave breadcrumbs in your error reporting: ```ts title="src/debug.ts" import type { AuthDebug } from "@monarcode/session-kit"; declare function addBreadcrumb(crumb: { category: string; message: string; data: object; }): void; /** Pass as `createAuth`'s `debug` option. */ export const debug: AuthDebug = (event) => addBreadcrumb({ category: `auth.${event.type}`, message: event.message, data: { ...event.data, code: event.error?.code }, }); ``` ## Tokens and user data Events leave out tokens, saved values and the user's fields. A session appears as its `sessionId`, and an error as its code and message, without its `cause` or `issues`. To include them, pass `{ sensitive: true }`. Anyone who reads such logs can act as the user, so keep it to development builds. To set both, pass `{ log, sensitive }`, where `log` is your function. ## What stays stable An event's `type` is one of a fixed set, such as `"refresh"` or `"storage"`, so you can filter on it. The wording of `message` and the fields in `data` may change in any release. A storage adapter can add its own events through the optional `debug` member of `AuthStorage`, as [`secureStorage()`](https://session-kit.pages.dev/docs/setups/expo#debugging) does. # Effect and effetch (https://session-kit.pages.dev/docs/effect) In this guide, you use the session from an [Effect](https://effect.website) app. You read it as a service, call your API with [effetch](https://github.com/monarcode/effetch), and cache each account's data with [effetch-query](https://github.com/monarcode/effetch-query). Two entry points do this: - **`@monarcode/session-kit/effect`** gives your Effects the session as the `Auth` service. It wraps the client you already have, so React, your router and your Effects share one session. Its Effects fail with a tagged `AuthFailure`, which `Effect.catchTag` handles like your own errors. - **`@monarcode/session-kit/effetch`** has the `sessionBearer` plugin. It sends the access token with each effetch call, and refreshes after a 401. A complete app is in [`examples/tanstack-start-spa-effetch`](https://github.com/monarcode/session-kit/tree/main/examples/tanstack-start-spa-effetch). Every export is listed in the reference for [`./effect`](https://session-kit.pages.dev/docs/reference/effect) and [`./effetch`](https://session-kit.pages.dev/docs/reference/effetch). ## Before you begin - Read [Getting started](https://session-kit.pages.dev/docs/getting-started). This guide builds the same app with Effect Schemas and effetch. [Refresh](https://session-kit.pages.dev/docs/configuration/refresh) and [Errors](https://session-kit.pages.dev/docs/configuration/errors) explain how the client renews and fails. - Know Effect's generators, services and layers. ## Installation To install the packages, run the following command: ```npm npm install @monarcode/session-kit effect @monarcode/effetch ``` `effect` and `@monarcode/effetch` are optional peer dependencies of Session kit. `./effect` and `./effetch` need `effect` 4.1 or newer, and `./effetch` also needs `@monarcode/effetch`. To [cache with effetch-query](#cache-with-effetch-query), also install effetch-query 0.1.0-rc.2 or newer, and its `@effect/atom-react` peer for React: ```npm npm install @monarcode/effetch-query @effect/atom-react ``` ## 1. Describe your backend This is the backend from [Getting started](https://session-kit.pages.dev/docs/getting-started#1-talk-to-your-backend), as Effect Schemas: the user, the tokens, and the body DummyJSON explains a failure with. They're schemas only, with no client, so the rest of the app, and the [server](#on-the-server), import them from one place: ```ts title="src/backend.ts" import * as Schema from "effect/Schema"; export const API_BASE_URL = "https://dummyjson.com"; export const SessionUser = Schema.Struct({ id: Schema.Finite, username: Schema.String, email: Schema.String, firstName: Schema.String, }); /** The user, as Session kit's `user` option takes it. */ export const userSchema = Schema.toStandardSchemaV1(SessionUser); export const SessionTokens = Schema.Struct({ accessToken: Schema.String, refreshToken: Schema.String, }); /** DummyJSON returns the user's fields beside the tokens. */ export const SignInResponse = Schema.Struct({ ...SessionTokens.fields, ...SessionUser.fields, }); export const ApiError = Schema.Struct({ message: Schema.String }); ``` ## 2. Refresh with effetch The refresh gets an effetch client of its own, made with its own `Fetch.make`, for two reasons: - The app's API client [waits for a refresh](#6-call-your-api-with-effetch) after a 401. A refresh sent through it would wait for itself. - The app's runtime is built on the session client this refresh belongs to, so the refresh can't run in it. It provides the `FetchHttpClient` effetch sends with itself. `refreshSession` resolves `null` when the backend rejects the refresh token, and fails for anything else, as [Refresh](https://session-kit.pages.dev/docs/configuration/refresh) explains. The server uses it too: ```ts title="src/refresh.ts" import * as Fetch from "@monarcode/effetch"; import * as Effect from "effect/Effect"; import * as FetchHttpClient from "effect/http/FetchHttpClient"; import * as HttpClient from "effect/http/HttpClient"; import { API_BASE_URL, SessionTokens } from "./backend.js"; // Not the app's API client: its session plugin waits for this refresh. const makeRefreshClient = Fetch.make({ baseUrl: API_BASE_URL }).pipe( // No tracing headers, as for the API client in step 6. Effect.provideService(HttpClient.TracerPropagationEnabled, false), ); /** New tokens. `null` signs out; a failure keeps the session for a retry. */ export const refreshSession = (refreshToken: string, signal: AbortSignal) => Effect.runPromise( Effect.gen(function* () { const client = yield* makeRefreshClient; return yield* client.post("/auth/refresh", { body: { refreshToken }, output: SessionTokens, }); }).pipe( Effect.catchTag("HttpError", (error) => error.status === 401 || error.status === 403 ? Effect.succeed(null) : Effect.fail(error), ), Effect.provide(FetchHttpClient.layer), ), { signal }, ); ``` ## 3. Register the client Create the client as usual, then register its type once, so every Effect carries your user. The same registration types the hooks from `@monarcode/session-kit/react`: ```ts title="src/auth.ts" import { createAuth, webStorage } from "@monarcode/session-kit"; import { userSchema } from "./backend.js"; import { refreshSession } from "./refresh.js"; export const auth = createAuth({ name: "app", user: userSchema, storage: webStorage(), refresh: ({ refreshToken, signal }) => refreshSession(refreshToken, signal), }); declare module "@monarcode/session-kit" { interface Register { auth: typeof auth; } } ``` ## 4. Provide the service `Auth.layer` provides the client as the `Auth` service. It doesn't mount the client: `AuthProvider`, `SessionGate` or the router adapters still do that. To provide it, create the layer once: ```ts title="src/layer.ts" import { Auth } from "@monarcode/session-kit/effect"; import { auth } from "./auth.js"; export const AuthLive = Auth.layer(auth); ``` In tests, provide a layer over another client instead. ## 5. Read the session `Auth.session` is the current session. If the access token expired, it refreshes first. It fails with `UNAUTHENTICATED` when no one is signed in. For code that also runs signed out, `Auth.sessionOption` gives `Option.none()` instead: ```ts title="src/greeting.ts" import { Auth } from "@monarcode/session-kit/effect"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import { AuthLive } from "./layer.js"; export const greeting = Auth.sessionOption.pipe( Effect.map( Option.match({ onNone: () => "Welcome", onSome: (session) => `Welcome back, ${session.user.firstName}`, }), ), ); export const showGreeting = () => Effect.runPromise(Effect.provide(greeting, AuthLive)); ``` ## 6. Call your API with effetch Make the app's API client with effetch's `Fetch.make`: the backend's base URL, its error body, and the plugins every call goes through. The `sessionBearer` plugin does what [`createAuthFetch`](https://session-kit.pages.dev/docs/client/requests) does: it sends the access token, and refreshes after a 401. Put `sessionBearer` last, so that a retry reads the current token. Give it the client's `baseUrl`, since the token goes only to that origin. `Fetch.make` then needs the `Auth` service, once: ```ts title="src/api.ts" import * as Fetch from "@monarcode/effetch"; import { retry } from "@monarcode/effetch/plugins"; import { sessionBearer } from "@monarcode/session-kit/effetch"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as HttpClient from "effect/http/HttpClient"; import * as Layer from "effect/Layer"; import { API_BASE_URL, ApiError } from "./backend.js"; const make = Fetch.make({ baseUrl: API_BASE_URL, defaultError: ApiError, plugins: [retry(), sessionBearer({ baseUrl: API_BASE_URL, optional: true })], }).pipe( // Effect adds tracing headers to every request, which fail the preflight // of an API that doesn't allow them. Effect.provideService(HttpClient.TracerPropagationEnabled, false), ); export class ApiClient extends Context.Service< ApiClient, Effect.Success >()("ApiClient") {} /** The API client, over whichever `Auth` is provided. */ export const ApiClientLive = Layer.effect(ApiClient, make); ``` Calls can fail with `AuthFailure`. When one client serves public and private endpoints, as this one does, give the plugin `optional: true`: a call made while signed out is then sent without a token, and a private endpoint answers with its own 401. A locked client still fails with `LOCKED`. Effect code yields the client and calls its methods, such as `get` and `post`. The sign-in call skips the plugin with `sessionBearer: false`, because it's how the user gets a token. To read the current user and to sign in, add `src/user.ts`: ```ts title="src/user.ts" import * as Effect from "effect/Effect"; import { ApiClient } from "./api.js"; import { SessionUser, SignInResponse } from "./backend.js"; export const currentUser = Effect.gen(function* () { const client = yield* ApiClient; return yield* client.get("/auth/me", { output: SessionUser }); }); export const login = (username: string, password: string) => Effect.gen(function* () { const client = yield* ApiClient; return yield* client.post("/auth/login", { body: { username, password }, output: SignInResponse, plugins: { sessionBearer: false }, }); }); ``` With [another HTTP client](https://session-kit.pages.dev/docs/client/other-http-clients), use `Auth.credentials`, the access token. It's kept apart from `Auth.session`, so code that only needs the user never holds a token. After a 401, `Auth.renew(credentials)` gets new credentials once for every caller. ## 7. Run Effects in one runtime Compose the app's layers in one place: the API client, the session, and the `FetchHttpClient` effetch sends with, each made once: ```ts title="src/runtime.ts" import * as FetchHttpClient from "effect/http/FetchHttpClient"; import * as Layer from "effect/Layer"; import * as ManagedRuntime from "effect/ManagedRuntime"; import { ApiClientLive } from "./api.js"; import { AuthLive } from "./layer.js"; const AppLayer = ApiClientLive.pipe( Layer.provideMerge(AuthLive), Layer.provide(FetchHttpClient.layer), ); /** One runtime for the app's lifetime. */ export const appRuntime = ManagedRuntime.make(AppLayer); export type AppRuntime = typeof appRuntime; ``` Run every Effect with this runtime, as in `appRuntime.runPromise(currentUser)`, instead of providing `AuthLive` as step 5 does. ## Handle failures Every `Auth` Effect fails with `AuthFailure`. Its `code` is the client's [`AuthErrorCode`](https://session-kit.pages.dev/docs/configuration/errors), and its `cause` is the `AuthError` itself. Misuse, which the client reports as `INVALID_OPTIONS`, is a defect rather than a failure. For example, to show "Guest" when no one is signed in: ```ts title="src/account.ts" import { Auth } from "@monarcode/session-kit/effect"; import * as Effect from "effect/Effect"; export const accountName = Auth.session.pipe( Effect.map((session) => session.user.firstName), Effect.catchTag("AuthFailure", (failure) => failure.code === "UNAUTHENTICATED" ? Effect.succeed("Guest") : Effect.fail(failure), ), ); ``` A client method that `Auth` doesn't wrap, such as `auth.unlock()`, rejects with an `AuthError`. `Auth.fromAuthError` turns it into an `AuthFailure`. Anything else it's given, such as a network error from `createAuthFetch`, is a defect. ## Update the user `Auth.updateUser` takes the user, or an Effect that fetches it. The session is captured before the Effect runs, so a profile fetched for one account fails with `SESSION_CHANGED` instead of landing on another. The Effect's own failure comes through unchanged: ```ts title="src/profile.ts" import { Auth } from "@monarcode/session-kit/effect"; import { currentUser } from "./user.js"; export const reloadProfile = Auth.updateUser(currentUser); ``` ## Watch the session `Auth.changes` is a `Stream` of the client's `AuthState`: the current state, then every change, until the stream ends: ```ts title="src/watch.ts" import { Auth } from "@monarcode/session-kit/effect"; import * as Effect from "effect/Effect"; import * as Stream from "effect/Stream"; export const logStatus = Auth.changes.pipe( Stream.map((state) => state.status), Stream.changes, Stream.runForEach((status) => Effect.log(`Auth is ${status}`)), ); ``` ## Cache with effetch-query effetch-query caches endpoints: a route and its schemas, defined with `Fetch.endpoint`, which it calls with the app's API client. Its queries and mutations run with the app's runtime, so components never run an Effect by hand. Give it the client's `defaultError`, which an endpoint can't read from the client: ```ts title="src/query.ts" import { QueryClient } from "@monarcode/effetch-query"; import { endpoints } from "@monarcode/effetch-query/effetch"; import type * as ManagedRuntime from "effect/ManagedRuntime"; import { ApiClient } from "./api.js"; import { ApiError } from "./backend.js"; import { appRuntime, type AppRuntime } from "./runtime.js"; declare module "@monarcode/effetch-query" { interface Register { readonly services: ManagedRuntime.ManagedRuntime.Services; } } export const api = endpoints(ApiClient, { defaultError: ApiError }); export const makeQueryClient = () => QueryClient.make({ runtime: appRuntime }); ``` A cache must not outlive the account that filled it. Let `QueryProvider` own its client (`make` instead of `client`) and pass the session ID as `resetKey`. Signing out or switching accounts then disposes the client, with its cache and its requests in flight, and renders the tree with a new one: ```tsx title="src/query-provider.tsx" import type { ReactNode } from "react"; import { QueryProvider } from "@monarcode/effetch-query/react"; import { useAuth } from "@monarcode/session-kit/react"; import { makeQueryClient } from "./query.js"; export function Queries({ children }: { children: ReactNode }) { const account = useAuth((state) => state.sessionId ?? "signed-out"); return ( {children} ); } ``` Render it inside `AuthProvider` or `SessionGate`, above every `Suspense` boundary. A refresh, a failed refresh or a profile update keeps the `sessionId`, and so the cache. So does locking: queries that need a token fail with `LOCKED` until `unlock()`, then refetch as usual. Until a saved session is restored, the key is `"signed-out"`, and the restore replaces that client. A feature defines the endpoints it caches, and its components use the queries and mutations made of them: ```tsx title="src/features/account.tsx" import * as Fetch from "@monarcode/effetch"; import { useMutation, useQuery } from "@monarcode/effetch-query/react"; import { useAuthClient } from "@monarcode/session-kit/react"; import * as Exit from "effect/Exit"; import * as AsyncResult from "effect/reactivity/AsyncResult"; import * as Schema from "effect/Schema"; import { SessionUser, SignInResponse } from "../backend.js"; import { api } from "../query.js"; const signInMutation = api.mutation( Fetch.endpoint("POST /auth/login", { input: Schema.Struct({ username: Schema.String, password: Schema.String }), output: SignInResponse, }), { plugins: { sessionBearer: false } }, ); const profileQuery = api.query( Fetch.endpoint("GET /auth/me", { output: SessionUser }), ); export function SignInButton() { const auth = useAuthClient(); const { result, run } = useMutation(signInMutation); async function signIn() { const exit = await run({ // DummyJSON's documented test user. body: { username: "emilys", password: "emilyspass" }, }); // A failed request shows in `result`. if (Exit.isFailure(exit)) return; const { accessToken, refreshToken, ...user } = exit.value; await auth.signIn({ accessToken, refreshToken, user }); } return ( ); } export function Email() { const result = useQuery(profileQuery()); return AsyncResult.match(result, { onInitial: () =>

Loading…

, onFailure: () =>

Couldn't load your profile.

, onSuccess: ({ value }) =>

{value.email}

, }); } ``` The owned client is for the browser and React Native. With server rendering, give `QueryProvider` a `client` per request instead, as effetch-query's README describes. Make it on the server over that request's session, as [Render queries on the server](#render-queries-on-the-server) shows. ## On the server With server rendering, server functions, actions and route handlers run Effects too, and the session they read is the cookie the server keeps ([Server sessions](https://session-kit.pages.dev/docs/server-rendering/server-sessions)). Each framework's adapter gives one request's session (`RequestAuth`), and `Auth.layer` takes it, so `Auth.session`, `Auth.credentials` and `sessionBearer` work there: `sessionBearer` sends the cookie's token, and saves a renewed one after a 401. On the server, the session comes from `createServerAuth`, with the user schema the app registers and the same refresh. The server's files build on `src/backend.ts`, `src/refresh.ts`, `src/api.ts` and `src/user.ts` from the earlier steps. In a server-rendered app, the registered client is the browser's `createMirrorAuth` client, not `createAuth`. Each framework's guide sets it up: [TanStack Start](https://session-kit.pages.dev/docs/setups/tanstack-start), [Next.js](https://session-kit.pages.dev/docs/setups/next) and [React Router](https://session-kit.pages.dev/docs/setups/react-router-ssr). To create the server's session engine, add `src/server/auth.ts`: ```ts title="src/server/auth.ts" import { createServerAuth } from "@monarcode/session-kit/server"; import { userSchema } from "../backend.js"; import { refreshSession } from "../refresh.js"; declare const secret: string; // from the environment export const serverAuth = createServerAuth({ name: "app", user: userSchema, secret, refresh: ({ refreshToken, signal }) => refreshSession(refreshToken, signal), }); /** One request's session, as your framework's adapter gives it. */ export type ServerSession = ReturnType; ``` A request's session belongs to that request, and so does everything that uses it. Build those per request, and keep a runtime for what belongs to no user: ```ts title="src/server/run.ts" import { Auth } from "@monarcode/session-kit/effect"; import * as Effect from "effect/Effect"; import * as FetchHttpClient from "effect/http/FetchHttpClient"; import * as Layer from "effect/Layer"; import * as ManagedRuntime from "effect/ManagedRuntime"; import { type ApiClient, ApiClientLive } from "../api.js"; import type { ServerSession } from "./auth.js"; /** Built once: what belongs to no user. */ const serverRuntime = ManagedRuntime.make(FetchHttpClient.layer); /** Built per request: its session, and the API client that uses it. */ export const requestLayer = (session: ServerSession) => ApiClientLive.pipe(Layer.provideMerge(Auth.layer(session))); /** Runs `effect` with the request's `session`. */ export function runRequest( session: ServerSession, effect: Effect.Effect, ): Promise { return serverRuntime.runPromise( Effect.provide(effect, requestLayer(session)), ); } ``` The runtime has no `Auth` and no API client, so an Effect that needs them doesn't compile until it goes through `runRequest`, and nothing a service keeps outlives its request. Building them is cheap next to the request your API answers. Each framework's adapter gives the request's session, in the server function, action or handler: ```ts title="src/server/profile.ts" import { currentUser } from "../user.js"; import type { ServerSession } from "./auth.js"; import { runRequest } from "./run.js"; // TanStack Start: startAuth(serverAuth). React Router: // routerAuth(serverAuth)(context). Next.js, from // nextSession(serverAuth, { cookies, redirect }): await session.writable() // in a Server Function, or await session.readOnly() in a Server Component. export const profileFor = (session: ServerSession) => runRequest(session, currentUser); ``` ### Sign in on the server On a request's session, sign in with its own `signIn`, which takes the tokens and the user, and update the user with its own `updateUser`. `Auth.signIn` and `Auth.updateUser` take what the browser client's methods do, so they aren't available there. Ask your backend with an Effect, then seal what it returns: ```ts title="src/server/sign-in.ts" import { login } from "../user.js"; import type { ServerSession } from "./auth.js"; import { runRequest } from "./run.js"; export async function signIn( session: ServerSession, username: string, password: string, ) { const { accessToken, refreshToken, ...user } = await runRequest( session, login(username, password), ); return session.signIn({ accessToken, refreshToken, user }); } ``` ### Render queries on the server To render a page with its data, make a query cache per request, on the server, over that request's layer, and dispose of it once the page is sent: ```ts title="src/server/query-client.ts" import { QueryClient } from "@monarcode/effetch-query"; import * as FetchHttpClient from "effect/http/FetchHttpClient"; import * as Layer from "effect/Layer"; import type { ServerSession } from "./auth.js"; import { requestLayer } from "./run.js"; export const queryClientFor = (session: ServerSession) => QueryClient.make({ layer: requestLayer(session).pipe(Layer.provide(FetchHttpClient.layer)), }); ``` Give it no `memoMap` ([Things to know](#things-to-know) says why). Prefetch before the page starts streaming, or refresh in middleware first, since a cookie set once streaming has begun is lost. In the browser, the cache is the owned one from [Cache with effetch-query](#cache-with-effetch-query), over the mirror client. ### Things to know - **One registration.** An app with server rendering registers its mirror client, and that registration types `Auth` on the server too. `Auth.layer` takes a request's session whose user is the registered one. In the browser, `Auth.layer(mirror)` is built where the mirror is created. `Auth.signIn` needs the mirror's `signIn` option there. `sessionBearer` needs `clientCredentials: true` in `createServerAuth`, or the mirror's `credentials` option. `Auth.updateUser` isn't available, since the user comes from the server. - **Keep server modules free of the browser client.** In this guide, the server's files import only `src/backend.ts`, `src/refresh.ts`, `src/api.ts` and `src/user.ts`, which create no Session kit client. Keep it that way in your app. A module the server imports, such as the one that defines `ApiClientLive` or the user schema, mustn't also create a browser client or its layer, or the server loads it too. - **Name the services in `runRequest`'s signature,** as `src/server/run.ts` does, rather than inferring them from `requestLayer`. With a mirror registered, the registration depends on the server functions the mirror calls, which call `runRequest`, and TypeScript reports a circular type. For the same reason, the server functions the mirror is made from, such as its `revalidate` and `signIn`, read the request's session directly, not `Auth.session`. - **Keep a request's layer off a shared `memoMap`,** such as the runtime's or one passed to `QueryClient.make`. Layers are shared by identity there, so overlapping requests can get one build: a service that keeps `Auth` when it's built would then serve one request's session to another. - **Requests that can't set cookies,** such as a Next.js Server Component's. `Auth.session` returns the cookie's session as it is. `Auth.credentials` and `sessionBearer` fail with `REFRESH_FAILED` once the access token has expired, or after a 401, since nothing new can be saved. Refresh first, in middleware or Proxy, as the framework guides do. Sign out in an action or route handler. - **Only `Auth`'s Effects.** A request's session isn't your browser client, so don't reach for client methods through `Auth.use` there. - **`Auth.layer` binds a session from `startAuth`** to the request that's current when it's called, so call it per request, as `requestLayer` does. ## What stays on the client `Auth` covers what Effect code does with a session: reading it, signing in and out, and updating the user. Everything else stays on the client, which React and the routers already drive: `lock` and `unlock`, `refresh` and `retry`, `mount`, and the client's options. On the server, a request's session signs in and updates the user. ## Next steps - [Server sessions](https://session-kit.pages.dev/docs/server-rendering/server-sessions): how the server keeps the session in a cookie, and refreshes it. - [Locking](https://session-kit.pages.dev/docs/client/locking): lock a native app's session. `Auth.session` and `sessionBearer` then fail with `LOCKED` until it's unlocked. - [Other HTTP clients](https://session-kit.pages.dev/docs/client/other-http-clients): the calls `sessionBearer` is built on, for any other client. # Entry points (https://session-kit.pages.dev/docs/reference) Session kit publishes one entry point for each part of the package. This page lists them, with what each one exports. Each entry point's page documents its exports, with their options and types. ## Entry points Internal `dist` paths aren't part of the public API. Import from these paths only: | Import | Exports | | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | [`@monarcode/session-kit`](https://session-kit.pages.dev/docs/reference/core) | `createAuth`, `createMirrorAuth`, `fromAccessToken`, `webStorage`, `memoryStorage`, `isSignedIn`, `safeReturnTo`, `loginHref`, `AuthError`, types | | [`@monarcode/session-kit/react`](https://session-kit.pages.dev/docs/reference/react) | `AuthProvider`, `useAuth`, `useAuthClient`, `createAuthHooks`, `useMirrorAuth`, types | | [`@monarcode/session-kit/tanstack-router`](https://session-kit.pages.dev/docs/reference/tanstack-router) | `connectAuth`, `requireSession`, `redirectIfSignedIn`, `SessionOutlet`, `useAuth`, `useAuthClient`, types | | [`@monarcode/session-kit/react-router`](https://session-kit.pages.dev/docs/reference/react-router) | `requireSession`, `requireSessionLoader`, `redirectIfSignedIn`, `redirectIfSignedInLoader`, `SessionOutlet`, `useAuthRevalidation`, types | | [`@monarcode/session-kit/react-router/server`](https://session-kit.pages.dev/docs/reference/react-router-server) | `sessionMiddleware`, `requireSession`, `redirectIfSignedIn`, `sessionRoute`, `routerAuth`, `cookieJarMiddleware`, `routerJar`, types | | [`@monarcode/session-kit/http`](https://session-kit.pages.dev/docs/reference/http) | `createAuthFetch`, types | | [`@monarcode/session-kit/server`](https://session-kit.pages.dev/docs/reference/server) | `createServerAuth`, `sessionHandler`, types | | [`@monarcode/session-kit/tanstack-start`](https://session-kit.pages.dev/docs/reference/tanstack-start) | `connectSession`, `loadSession` | | [`@monarcode/session-kit/tanstack-start/server`](https://session-kit.pages.dev/docs/reference/tanstack-start-server) | `startAuth`, `refreshSession`, `sessionRoute`, `startJar`, types | | [`@monarcode/session-kit/next`](https://session-kit.pages.dev/docs/reference/next) | `nextSession`, `sessionRoute`, `nextJar`, `proxyJar`, types | | [`@monarcode/session-kit/expo`](https://session-kit.pages.dev/docs/reference/expo) | `secureStorage`, `SessionGate`, `deepLinkHandler`, `rememberDeepLink`, types | | [`@monarcode/session-kit/effect`](https://session-kit.pages.dev/docs/reference/effect) | `Auth`, `AuthFailure`, types | | [`@monarcode/session-kit/effetch`](https://session-kit.pages.dev/docs/reference/effetch) | `sessionBearer`, types | ## Bundle size The package is tree-shakeable, so a bundle holds only what you import. An entry point you don't import adds nothing. ## Errors Errors from Session kit's own logic are `AuthError`s. In `./effect` and `./effetch`, Effects fail with `AuthFailure`, which carries the same code. The exception is `INVALID_OPTIONS`: it's misuse, so the Effect dies with the `AuthError` instead. See [Error codes](https://session-kit.pages.dev/docs/reference/error-codes) for every `AuthErrorCode`. # Core (https://session-kit.pages.dev/docs/reference/core) ```ts import { … } from "@monarcode/session-kit"; ``` Guide: [Getting started](https://session-kit.pages.dev/docs/getting-started). ## Functions ### createAuth Creates an auth client. It keeps the session your backend issued, saves it, refreshes its tokens, and tells your app when it changes. ### createMirrorAuth Creates a browser client ([`MirrorAuth`](#mirrorauth)) for a session your server keeps in an encrypted cookie, with `createServerAuth` from `@monarcode/session-kit/server`. ### fromAccessToken Reads the user from the access token, rather than from what `signIn` is given. Pass the result as `createAuth`'s `user` option. ### isSignedIn Whether someone is signed in: `true` while `authenticated` or `refreshing`. It narrows `state`, so `state.user` is set when it's `true`. ### loginHref Builds the sign-in page's URL, with `?redirectTo=` set to `returnTo`, so the user comes back after signing in. The guards use it. Use it in a guard of your own, such as in a route handler. ### memoryStorage Keeps the session in memory only. It ends when the page unloads, and other tabs never see it. Use it in tests, or where tokens must never be written to disk. Each call has its own entries. ### safeReturnTo Makes a "send me back here after sign-in" path safe to redirect to, such as a `?redirectTo=` search param. It returns the path, normalized, when it leads somewhere in your app, query and fragment included. ### webStorage Saves the session in the browser's `localStorage`, or in `sessionStorage` with `{ area: "session" }`. With `localStorage`, tabs follow each other's changes, such as a sign-out. Tabs take turns to refresh where the browser supports Web Locks. ## Classes ### AuthError The error Session kit throws, rejects with, or shows in `state.error`. ## Types ### AccessTokenUser A user read from the access token, made by [`fromAccessToken`](#fromaccesstoken). Pass it as `createAuth`'s `user` option. `C` is the token's claims, and `U` is the user made from them. ### AuthClient The auth client that `createAuth` returns. It holds the session, saves it, refreshes it, and tells your app when it changes. ### AuthDebug What `createAuth`'s `debug` option takes: - `true`: log every event to the console. - A function: receive every [`AuthDebugEvent`](#authdebugevent). - [`AuthDebugOptions`](#authdebugoptions): choose where events go, and whether they include secrets. - `false`, or leaving it out: no logging. ### AuthDebugEvent One thing auth did, sent to the `debug` option ([`AuthDebug`](#authdebug)). ### AuthDebugOptions The object form of `createAuth`'s `debug` option ([`AuthDebug`](#authdebug)): where events go, and whether they include secrets. ### AuthDebugOptions The object form of `createAuth`'s `debug` option (AuthDebug): where events go, and whether they include secrets. | Prop | Type | Description | | ------------ | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `log?` | `function` | Receives every event. Default: one `console.info` line per event. | | `sensitive?` | `union` | Also puts tokens, saved values, user fields, whole deep-link paths and error details in events. Anyone who reads such logs can act as the user, so use it only in development. Default: `false` | ### AuthDebugType Which part of auth an [`AuthDebugEvent`](#authdebugevent) comes from, such as `refresh` or `storage`. These names don't change between releases, so filter on them. ### AuthErrorCode What went wrong: the `code` of every [`AuthError`](#autherror). Match on it rather than on the message, which may change in any release. ### AuthErrorOptions Options for creating an [`AuthError`](#autherror) yourself, such as in a test or a custom storage adapter: the standard `cause`, plus schema `issues`. ### AuthErrorOptions Options for creating an AuthError yourself, such as in a test or a custom storage adapter: the standard `cause`, plus schema `issues`. | Prop | Type | Description | | --------- | --------- | ---------------------------------------------------------- | | `cause?` | `unknown` | | | `issues?` | `array` | The schema's issues, for a `USER_VALIDATION_FAILED` error. | ### AuthLocking Encrypts the saved tokens with a key your app holds, such as one behind Face ID. Pass it as the `locking` option of [`AuthOptions`](#authoptions). ### AuthOptions Options for `createAuth`. The client's user types ([`User`](#user), [`UserInput`](#userinput)) are inferred from the `user` option. ### AuthOptions Options for `createAuth`. The client's user types (User, UserInput) are inferred from the `user` option. | Prop | Type | Description | | ---------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | `string` | Names this auth, so several can share one storage. It's used in the storage key (`:auth:session`) and in the lock that keeps tabs from refreshing at once. Letters, digits, `_` or `-`, up to 64 characters. | | `user` | `Src` | Where the user comes from: a schema, or `fromAccessToken(…)`. A Standard Schema schema, such as Zod's, checks the `user` passed to `signIn`. Its output must be a plain JSON object that it accepts again unchanged, as a restored session is checked again. `fromAccessToken` reads the user from the access token instead. | | `storage` | `AuthStorage` | Where the session is saved, such as `webStorage()` (AuthStorage). | | `refresh?` | `RefreshFn>>` | Exchanges a refresh token for new tokens (RefreshFn). Without it, the user is signed out when the access token expires or your API rejects it. | | `revoke?` | `RevokeFn` | Asks your backend to revoke the tokens after `signOut()` (RevokeFn). | | `maxAge?` | `number` | How long a saved session lasts, in whole seconds. Each save, such as after a refresh, starts it again. A session saved longer ago isn't restored, and the user is signed out without an error. So is a session saved by another version of session-kit. Default: 30 days. | | `debug?` | `union` | Logs what auth does, for debugging: restores, refreshes, timers, storage and state changes. `true` logs to the console, and a function receives each event instead (AuthDebug). Tokens, user fields, saved values and error details are left out unless you pass `{ sensitive: true }`. Pass a development flag, such as Expo's `__DEV__`, to keep it out of production. Default: `false` | | `locking?` | `Src extends AccessTokenUser ? never : true \| AuthLocking` | Restores a saved session as `locked`. Its user shows, but its tokens wait for `unlock()`. `true` saves the tokens as they are, and `{ seal, open }` encrypts them with your app's key (AuthLocking). It's meant for native apps. In a browser, tabs stop following each other, so a sign-in, sign-out or lock in one tab doesn't reach the others. With rotating refresh tokens, two tabs that refresh the same session can sign one of them out. It needs a user schema, as a user read from the access token can't show while the token is locked. | ### AuthState The current auth state, from `state.get()` or `useAuth()`. It holds no tokens. ### AuthStatus Where the session stands: `state.status` ([`AuthState`](#authstate)). - `initializing`: the saved session hasn't been read yet. - `authenticated`: signed in, with an access token that hasn't expired or been rejected. - `refreshing`: signed in, and replacing an access token that expired or was rejected. - `locked`: signed in, but the tokens wait for `unlock()` (with `locking`). - `unauthenticated`: signed out. - `unavailable`: the access token expired or was rejected and couldn't be refreshed, or the saved session couldn't be read. `state.error` usually says why. On a client, `retry()` tries again. ### AuthStorage Where auth saves the session: `createAuth`'s `storage` option. Use [`webStorage`](#webstorage) in browsers, `secureStorage()` from `@monarcode/session-kit/expo` in Expo apps, and [`memoryStorage`](#memorystorage) in tests. Implement it yourself for another store. ### AuthStorage Where auth saves the session: `createAuth`'s `storage` option. Use webStorage in browsers, `secureStorage()` from `@monarcode/session-kit/expo` in Expo apps, and memoryStorage in tests. Implement it yourself for another store. Values are strings in a format auth owns. Methods may return promises. Auth runs them one at a time, in order, so a store that answers out of order is still safe. | Prop | Type | Description | | ------------ | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `get` | `function` | Returns the value saved under `key`, or `null` when there's none. If it answers synchronously, a saved session shows before the first render. | | `set` | `function` | Saves `value` under `key`. `meta.expiresAt` is when the saved session stops being valid, in Unix milliseconds, for a store that can expire entries itself. | | `remove` | `function` | Removes the value saved under `key`. | | `subscribe?` | `function` | Calls `onChange` when another tab changes `key`, and returns a function that stops listening. Leave it out when nothing else shares the store. | | `lock?` | `function` | Runs `work` while holding the lock `name` in every tab that shares the store, so only one tab refreshes at a time. Aborting `signal` stops waiting for the lock. Leave it out when nothing else shares the store. | | `debug?` | `function` | Lets your adapter add its own events to the debug log, such as why it discarded a saved value. Each `createAuth` with `debug` on calls it with the key it saves under and a `report` function for that key. One adapter can serve several auths, so keep a `report` per key. `sensitive` says whether saved values may appear in `data`. | ### ClientCredentials The access token as your server gives it to the browser, from `serverAuth.clientCredentials` in `@monarcode/session-kit/server`. `expiresIn` counts from when the server answered, so the browser's clock doesn't matter. ### Credentials The current access token, from `credentials.get()`, to send with your API requests. `createAuthFetch` from `@monarcode/session-kit/http` does this for you. ### FromAccessTokenOptions Options for [`fromAccessToken`](#fromaccesstoken). ### FromAccessTokenOptions Options for fromAccessToken. | Prop | Type | Description | | --------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `decode?` | `function` | Reads the claims from the access token. It may return a promise, but then a saved session can't show before the first render. The default doesn't verify the signature. That's fine in a browser, which trusts its own backend's responses. On a server, pass a function that verifies the token. Default: the JWT's payload, **unverified**. | ### MirrorAuth The browser client that [`createMirrorAuth`](#createmirrorauth) returns. It mirrors a session your server keeps in a cookie. ### MirrorAuthOptions Options for [`createMirrorAuth`](#createmirrorauth). The client talks to your server through `endpoint`, your session route, or through functions of your own, such as server functions, which call `serverAuth` from `@monarcode/session-kit/server`. Pass `endpoint`, or `revalidate` and `signOut`. ### MirrorAuthOptions Options for createMirrorAuth. The client talks to your server through `endpoint`, your session route, or through functions of your own, such as server functions, which call `serverAuth` from `@monarcode/session-kit/server`. Pass `endpoint`, or `revalidate` and `signOut`. | Prop | Type | Description | | -------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | `string` | Names this auth. Tabs tell each other about changes on the `BroadcastChannel` `:auth`. Letters, digits, `_` or `-`, up to 64 characters. | | `session` | `union` | The session the page was rendered with, from `serverAuth.getSession`, or `null`. The client starts from it, so there's no loading state. | | `endpoint?` | `string` | The path of your session route on this site, such as `"/api/session"`, which answers with `sessionRoute` or `sessionHandler` from the server entry points. The client reads the session, signs out and gets the access token through it. Pass it, or `revalidate` and `signOut`. `revalidate`, `signOut` and `credentials` win when you pass them too. It's the full path, with your app's base path, such as React Router's `basename` or Next's `basePath`. It's called from the browser only: on the server, a call through it rejects with `INVALID_OPTIONS`. | | `revalidate?` | `union` | Reads the session on your server again, with `serverAuth.getSession`, such as through a server function. Required without `endpoint`. The client calls it when the tab becomes visible again, unless the server answered in the last 10 seconds, when another tab signs in or out, and from `retry()` and `refresh()`. Pass one when the session is also read while the server renders, where `endpoint` can't be called, as in TanStack Start. Default: a `GET` to `endpoint`. | | `signOut?` | `union` | Signs out on your server, with `serverAuth.signOut`. The client's `signOut()` calls it, then signs out this tab and the others. Required without `endpoint`. Default: a `POST` to `endpoint`. | | `signIn?` | `function` | Signs in on the server with `input`, and resolves the new session. Use `serverAuth.signIn`, or your own sign-in that ends with it. Without this option, the client has no `signIn`. Call your function, then pass its result to `adopt`. | | `credentials?` | `MirrorCredentials` | Gets the access token from your server (MirrorCredentials), so page scripts can call your API with `createAuthFetch` or `sessionBearer`. Default: through `endpoint`, which serves it when `createServerAuth` has `clientCredentials`. Without either, `credentials.get()` and `credentials.renew()` reject with `INVALID_OPTIONS`. | | `debug?` | `union` | Logs what the client does, like `createAuth`'s `debug`. | ### MirrorCredentials How a [`MirrorAuth`](#mirrorauth) gets the access token from your server. It's the `credentials` option of [`MirrorAuthOptions`](#mirrorauthoptions). ### RefreshFn Exchanges a refresh token for new tokens. It's the `refresh` option of [`AuthOptions`](#authoptions). ### RefreshResult What your refresh callback ([`RefreshFn`](#refreshfn)) returns: - [`Tokens`](#tokens): the new tokens. They must last at least five seconds. Leave out `refreshToken` or `user` to keep the current one. - `null`: your backend rejected the refresh token. The user is signed out. ### Register Register your client's type here, once, so the hooks from `@monarcode/session-kit/react` and the Effect service carry your user. Without it, the user is typed `unknown`. ### RegisteredAuth The client registered in [`Register`](#register), or a client with an `unknown` user when none is. It's `never` when what's registered isn't a client. ### RevokeFn Asks your backend to revoke the tokens of a session that just signed out. It's the `revoke` option of [`AuthOptions`](#authoptions). ### SafeReturnToOptions Options for [`safeReturnTo`](#safereturnto). ### SafeReturnToOptions Options for safeReturnTo. | Prop | Type | Description | | ------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `loginPath?` | `union` | Your sign-in page's path, or several. A path to it is replaced with `"/"`, so signing in never sends the user back to sign in. Matched by path, ignoring case, percent-encoding, trailing slashes and any query. Default: `"/login"` | ### Session Who is signed in: the session ID and the user, from `getSession()`. It holds no tokens, so it's safe to keep in route context or pass around. ### SessionOf The session type of a client: what its `getSession` resolves, without `null`. It takes any client: one from `createAuth` or `createMirrorAuth`, the server's from `createServerAuth`, or a request's session from it. ### SignInInput What `signIn` takes: the [`Tokens`](#tokens), plus the `user` when a schema validates it. With `fromAccessToken` (`I` is `never`), it takes tokens only. ### Tokens The tokens your backend issues. Pass them to `signIn`, and return them from your refresh callback ([`RefreshFn`](#refreshfn)). ### Tokens The tokens your backend issues. Pass them to `signIn`, and return them from your refresh callback (RefreshFn). When the access token expires comes from the first of these that is set: 1. `expiresIn` or `expiresAt`. Pass one, not both. 2. A JWT access token's `exp - iat`, counted from when auth receives it. 3. A JWT access token's `exp` alone, compared with the device's clock. With none of them, the token never expires on its own. It's replaced only after your API rejects it. Prefer `expiresIn`, or a JWT with `iat`. Device clocks can be minutes off, and a clock that runs fast makes a fixed deadline look already passed, so `signIn` would reject the tokens. | Prop | Type | Description | | --------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `accessToken` | `string` | The token your API accepts. `createAuthFetch` sends it as `Authorization: Bearer `. | | `refreshToken?` | `string` | The token your refresh callback exchanges for new tokens. Without one, the user is signed out when the access token expires or is rejected. A refresh that returns none keeps the current one. | | `expiresIn?` | `number` | How long the access token lasts, in **seconds**, like OAuth's `expires_in` (`900` is 15 minutes). It's counted from when auth receives the token, so a wrong device clock doesn't matter. | | `expiresAt?` | `number` | When the access token expires, as Unix time in **milliseconds**, such as `Date.parse("2026-10-02T12:15:00Z")`. It's compared with the device's clock, so use it only when your backend gives no lifetime. | ### User The signed-in user's type, inferred from the `user` option: what `state.user` and `session.user` hold. ### UserInput The user that `signIn` and `updateUser` accept, inferred from the `user` option. It differs from [`User`](#user) when the schema has defaults or transforms. ### UserOf The user type of a client, as [`SessionOf`](#sessionof) finds it: the `user` of its session. ### UserSource What `createAuth`'s `user` option takes: a schema, such as Zod's, that checks the `user` passed to `signIn`, or [`fromAccessToken`](#fromaccesstoken), which reads the user from the access token. ### WebStorageOptions Options for [`webStorage`](#webstorage). ### WebStorageOptions Options for webStorage. | Prop | Type | Description | | ------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `area?` | `union` | Which browser storage to use. `"local"` keeps the session across browser restarts and shares it with every tab. `"session"` gives each tab its own session, which ends with the tab. Avoid `"session"` with rotating refresh tokens. A duplicated tab starts with the same refresh token, and the first to use it signs the other out. Default: `"local"` | # React (https://session-kit.pages.dev/docs/reference/react) ```ts import { … } from "@monarcode/session-kit/react"; ``` Guide: [Read the session in React](https://session-kit.pages.dev/docs/getting-started#4-read-the-session-in-react). ## Functions ### createAuthHooks Creates React hooks typed for one auth client. Use it when an app has several clients: each call has its own context, so call it once per client. With one client, register it and use [`useAuth`](#useauth), [`useAuthClient`](#useauthclient) and [`AuthProvider`](#authprovider) instead. ### useAuth Reads the current auth state, or the part `selector` picks, and re-renders when it changes. With a selector, it re-renders only when that part changes. ### useAuthClient Returns the client of the nearest [`AuthProvider`](#authprovider), for actions such as `signIn` and `signOut`. It's typed by the client you register in `Register`, from `@monarcode/session-kit` ([`ProvidedAuth`](#providedauth)). ### useMirrorAuth Creates a `createMirrorAuth` client once, from the session the page was rendered with, and keeps it in step with your server. Call it in the component that renders `AuthProvider`. ## Components ### AuthProvider Gives a client to the hooks below it, and mounts it while rendered, which starts expiry timers and following other tabs. Render it once, around your app. ## Types ### AuthHooks The provider and hooks [`createAuthHooks`](#createauthhooks) returns, typed for one client. ### AuthHooksOptions Options for [`createAuthHooks`](#createauthhooks). ### AuthHooksOptions Options for createAuthHooks. | Prop | Type | Description | | ------------ | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `useClient?` | `function` | Finds the client some other way than `AuthProvider`, such as from a context of your own. It's called as a hook, inside every hook, on every render. The hooks then need no `AuthProvider`, so mount the client another way. Default: the client of the nearest `AuthProvider` from the same `createAuthHooks` call. | ### AuthProviderProps Props for [`AuthProvider`](#authprovider), and for the one that [`createAuthHooks`](#createauthhooks) returns. ### AuthProviderProps Props for AuthProvider, and for the one that createAuthHooks returns. | Prop | Type | Description | | ----------- | ----------- | -------------------------------------------------------------- | | `client` | `Client` | The client for this tree. On a server, create one per request. | | `children?` | `ReactNode` | The tree that reads `client` through the hooks. | ### AuthSource What the React bindings need from an auth client: its `state`, and `mount` to start background work while rendered. A `createAuth` or `createMirrorAuth` client fits. ### ProvidedAuth The client `AuthProvider` takes and `useAuthClient` returns: the one you register in `Register`, from `@monarcode/session-kit`. ### StateOf The state type of a client: what `useAuth()` returns (`AuthState`). ### UseMirrorAuthOptions Options for [`useMirrorAuth`](#usemirrorauth). ### UseMirrorAuthOptions Options for useMirrorAuth. | Prop | Type | Description | | ---------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `create` | `function` | Creates the client from the session the page was rendered with. It's called once, on the first render. | | `session` | `union` | The session your server rendered this page with, or `null`. When a new server render brings another one, the client takes it. | | `onChange` | `function` | Called when the session changes in the browser, such as after `signOut()` or in another tab. Make it render on the server again, so its redirects run: `router.refresh()` in Next.js, or `revalidate` from `useRevalidator()` in React Router. It can be a new function on each render: the latest one is called. | # TanStack Router (https://session-kit.pages.dev/docs/reference/tanstack-router) ```ts import { … } from "@monarcode/session-kit/tanstack-router"; ``` Guide: [TanStack Router](https://session-kit.pages.dev/docs/setups/tanstack-router). ## Functions ### connectAuth Starts auth for a Router whose context holds the client as `auth`. It restores the session, runs expiry timers, follows other tabs, and runs guards such as `requireSession` again when the session changes. Call it once, beside the Router. ### redirectIfSignedIn Guards your sign-in page in `beforeLoad`, so a signed-in user goes straight to `redirectTo`, made safe with `safeReturnTo`. ### requireSession Guards a route in `beforeLoad`, so only signed-in users get through. They get `{ session }` in route context, typed and never `null`, with no tokens in it. ### useAuth Reads the current auth state of the client in Router context, or the part `selector` picks, and re-renders when it changes. With a selector, it re-renders only when that part changes. ### useAuthClient Returns the auth client from Router context, where it's `auth`, for actions such as `signIn` and `signOut`. It needs no `AuthProvider`: `connectAuth` mounts the client. ## Components ### SessionOutlet Renders the child routes of a route guarded by `requireSession`, but only while the session it accepted is still the signed-in one. ## Types ### AuthRouter What [`connectAuth`](#connectauth) needs from a TanStack Router: the client in its context as `auth`, and the methods that run guards again and clear cached route data. A Router made by `createRouter` with `context: { auth }` fits. ### RedirectIfSignedInOptions Options for [`redirectIfSignedIn`](#redirectifsignedin). ### RedirectIfSignedInOptions Options for redirectIfSignedIn. | Prop | Type | Description | | ------------ | --------- | -------------------------------------------------------------------------------------------------------- | | `redirectTo` | `unknown` | Where to send a signed-in user, usually the `redirectTo` search param. It's checked with `safeReturnTo`. | | `loginPath?` | `union` | Your sign-in page's path, or several. `redirectTo` never sends the user back to one. Default: `"/login"` | ### RequireSessionOptions Options for [`requireSession`](#requiresession). ### RequireSessionOptions Options for requireSession. | Prop | Type | Description | | ------------ | -------- | -------------------------------------------------------------------------------------------------------- | | `location` | `object` | The route's location, from `beforeLoad`'s `location`. Signed-out users come back to it after signing in. | | `loginPath?` | `string` | Your sign-in page's path, starting with `/`. Default: `"/login"` | ### RouterAuth The auth client type in your registered Router's context, as `auth`, which [`useAuthClient`](#useauthclient) returns. It's `never` until the Router is registered. ### SessionOutletProps Props for [`SessionOutlet`](#sessionoutlet). ### SessionOutletProps Props for SessionOutlet. | Prop | Type | Description | | ---------- | ------------ | ------------------------------------------------------------------------------------------ | | `pending?` | `ReactNode` | Shown while the guard checks the session again, such as after a sign-out. | | `client?` | `AuthSource` | The client to watch. Default: the nearest `AuthProvider`'s, then `auth` in Router context. | # React Router (https://session-kit.pages.dev/docs/reference/react-router) ```ts import { … } from "@monarcode/session-kit/react-router"; ``` Guide: [React Router](https://session-kit.pages.dev/docs/setups/react-router). ## Functions ### redirectIfSignedIn Sends a signed-in user from your sign-in page straight to `redirectTo`, made safe with `safeReturnTo`, inside a loader of your own. When the loader does nothing else, use [`redirectIfSignedInLoader`](#redirectifsignedinloader) instead. ### redirectIfSignedInLoader Makes the loader for your sign-in page, for `loader` or `clientLoader`. It reads `?redirectTo=` from the URL and works as [`redirectIfSignedIn`](#redirectifsignedin) does. A `loginPath` that doesn't start with `/` throws `INVALID_OPTIONS` when the loader is made. ### requireSession Checks for a signed-in user inside a loader of your own, in data or framework mode, and resolves `{ session }`, which holds no tokens. When the loader does nothing else, use [`requireSessionLoader`](#requiresessionloader) as the loader instead. ### requireSessionLoader Makes the loader that guards a route, for `loader` or `clientLoader`, so only signed-in users get through. It resolves `{ session }`, typed with the client's user, and works as [`requireSession`](#requiresession) does. ### useAuthRevalidation Runs your loaders again when the session changes, so guards such as `requireSession` check it again. That's on sign-in, sign-out, a switch of account, a profile update, a lock, or a refresh that fails once the access token has expired. A token refresh alone doesn't, nor does a failed refresh while the token is still valid. ## Components ### SessionOutlet Renders child routes only while someone is signed in, in any mode. It needs an `AuthProvider` above it, or a `client`. ## Types ### RedirectIfSignedInLoader The loader that [`redirectIfSignedInLoader`](#redirectifsignedinloader) makes. It reads `redirectTo` from the request's URL, and resolves `null` for a signed-out user. ### RedirectIfSignedInLoaderOptions Options for [`redirectIfSignedInLoader`](#redirectifsignedinloader). ### RedirectIfSignedInLoaderOptions Options for redirectIfSignedInLoader. | Prop | Type | Description | | ------------ | ------- | -------------------------------------------------------------------------------------------------------- | | `loginPath?` | `union` | Your sign-in page's path, or several. `redirectTo` never sends the user back to one. Default: `"/login"` | ### RedirectIfSignedInOptions Options for [`redirectIfSignedIn`](#redirectifsignedin). ### RedirectIfSignedInOptions Options for redirectIfSignedIn. | Prop | Type | Description | | ------------ | --------- | -------------------------------------------------------------------------------------------------------- | | `loginPath?` | `union` | Your sign-in page's path, or several. `redirectTo` never sends the user back to one. Default: `"/login"` | | `redirectTo` | `unknown` | Where to send a signed-in user, usually the `redirectTo` search param. It's checked with `safeReturnTo`. | ### RequireSessionLoader The loader that [`requireSessionLoader`](#requiresessionloader) makes. It resolves `{ session }`, typed with the client's user. ### RequireSessionLoaderOptions Options for [`requireSessionLoader`](#requiresessionloader). ### RequireSessionLoaderOptions Options for requireSessionLoader. | Prop | Type | Description | | ------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `loginPath?` | `string` | Your sign-in page's path, starting with `/`. Use the same one as `SessionOutlet`'s. Default: `"/login"` | | `basename?` | `string` | The router's `basename`, if it has one. Loaders see URLs that include it, but redirects add it again, so `redirectTo` must leave it out. | ### RequireSessionOptions Options for [`requireSession`](#requiresession). ### RequireSessionOptions Options for requireSession. | Prop | Type | Description | | ------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `loginPath?` | `string` | Your sign-in page's path, starting with `/`. Use the same one as `SessionOutlet`'s. Default: `"/login"` | | `basename?` | `string` | The router's `basename`, if it has one. Loaders see URLs that include it, but redirects add it again, so `redirectTo` must leave it out. | | `request` | `object` | The loader's `request`. Signed-out users come back to its URL after signing in. | ### SessionOutletProps Props for [`SessionOutlet`](#sessionoutlet). ### SessionOutletProps Props for SessionOutlet. | Prop | Type | Description | | -------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `loginPath?` | `string` | Your sign-in page's path, starting with `/`. Use the same one as `requireSession`'s. Default: `"/login"` | | `pending?` | `ReactNode` | Shown while the saved session is read and, in data or framework mode, until the loader has accepted a new session. | | `unavailable?` | `function` | Shown when the session is `unavailable`, such as after a failed refresh (UnavailableProps). Without it, the error is thrown to the nearest error boundary or error element. | | `client?` | `RetryableSource` | The client to watch. Default: the nearest `AuthProvider`'s. | ### UnavailableProps What the `unavailable` render prop of an adapter component, such as React Router's `SessionOutlet` or Expo's `SessionGate`, receives. ### UnavailableProps What the `unavailable` render prop of an adapter component, such as React Router's `SessionOutlet` or Expo's `SessionGate`, receives. | Prop | Type | Description | | ------- | ---------- | ------------------------------------------------------------------------------- | | `error` | `union` | Why access is unavailable, such as a failed refresh or a locked keychain. | | `retry` | `function` | Tries again, as `auth.retry()` does. A repeated failure stays in `state.error`. | # React Router server (https://session-kit.pages.dev/docs/reference/react-router-server) ```ts import { … } from "@monarcode/session-kit/react-router/server"; ``` Guide: [React Router with server rendering](https://session-kit.pages.dev/docs/setups/react-router-ssr). ## Functions ### cookieJarMiddleware Server middleware that gives each request a `CookieJar`, found with [`routerJar`](#routerjar). It sets the cookies written to the jar on the response, redirects and error responses included. ### redirectIfSignedIn Makes the loader for your sign-in page, which sends a signed-in user on to the page's `redirectTo` search param, made safe with `safeReturnTo`. Signed-out users see the page. React Router adds the app's `basename` to the redirect. ### requireSession Makes server middleware that lets only signed-in users through to a route and the routes nested in it ([`SessionGuard`](#sessionguard)). Their loaders and actions get the session from its `session(context)`, never `null`. ### routerAuth Returns a function that gives the session of the request React Router is handling (`RequestAuth`), with every `serverAuth` method and no cookie jar. Call it with `context` in server middleware, loaders and actions, after [`cookieJarMiddleware`](#cookiejarmiddleware). Make it once, at module level. ### routerJar The `CookieJar` for the request React Router is handling, to pass to `serverAuth` from `@monarcode/session-kit/server`. [`routerAuth`](#routerauth) passes it for you. Call it with `context` in server middleware, loaders and actions, after [`cookieJarMiddleware`](#cookiejarmiddleware). Every call during one request returns the same jar. ### sessionMiddleware The root route's server middleware, which gives each request a cookie jar and refreshes the session while the response can still set the cookie. Export it as the root route's `middleware`, or spread it first into a list of your own. ### sessionRoute Makes the loader and action of your session route, a resource route that answers `createMirrorAuth`'s `endpoint`: the browser client reads the session, signs out and gets the access token through it. It needs [`sessionMiddleware`](#sessionmiddleware) in the root route. ## Types ### RouterArgs The arguments React Router passes to server middleware and loaders, as far as the middleware and guards here read them. It's typed by its shape, like [`RouterContextLike`](#routercontextlike). ### RouterContextLike The `context` React Router passes to server middleware, loaders and actions in framework mode: a `RouterContextProvider`, one per request. [`routerAuth`](#routerauth) and [`cookieJarMiddleware`](#cookiejarmiddleware) find the request by it. It's typed by its shape, so this entry point doesn't depend on React Router's types. ### RouterLoader A loader typed by its shape, such as [`redirectIfSignedIn`](#redirectifsignedin)'s. ### RouterMiddleware Server middleware for a route's `middleware`, typed by its shape. ### RouterSessionRoute The loader and action [`sessionRoute`](#sessionroute) makes, for a resource route. ### SessionGuard Server middleware that lets only signed-in users through, made by [`requireSession`](#requiresession). Call `session(context)` in the loaders and actions it guards for the session, which is never `null` there. ### SessionGuardOptions Options for [`requireSession`](#requiresession). ### SessionGuardOptions Options for requireSession. | Prop | Type | Description | | ------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `loginPath?` | `string` | Your sign-in page's path in the app, starting with `/`, without the `basename`. Default: `"/login"` | | `basename?` | `string` | The app's `basename`, if it has one. React Router adds it to a loader's redirects but not to middleware's, so the guard adds it to its own. It leaves it out of `redirectTo`, which a loader then uses. | # HTTP (https://session-kit.pages.dev/docs/reference/http) ```ts import { … } from "@monarcode/session-kit/http"; ``` Guide: [Authenticated requests](https://session-kit.pages.dev/docs/client/requests). ## Functions ### createAuthFetch Creates a `fetch` for your API ([`AuthFetch`](#authfetch)) that sends the current access token as `Authorization: Bearer`. After a 401, it gets a new access token once. `GET` and `HEAD` requests are then sent again; other requests resolve the 401 for you to retry. If the request sent again gets a 401 too, the user is signed out, unless `signOutOnRepeated401` is off. ## Types ### AuthFetch The `fetch` that [`createAuthFetch`](#createauthfetch) returns. Call it with a path under your API's base URL, and `init` as you would pass to `fetch`. It takes a `URL` or a `Request` too, as `fetch` does. ### AuthFetchClient The part of an auth client that [`createAuthFetch`](#createauthfetch) uses. A `createAuth` or `createMirrorAuth` client fits, and so does a request's session on the server (`RequestAuth`). ### AuthFetchOptions Options for [`createAuthFetch`](#createauthfetch). ### AuthFetchOptions Options for createAuthFetch. | Prop | Type | Description | | ----------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `baseUrl` | `string` | Your API's base URL. It must be absolute, such as `https://api.example.com/`. The access token goes only to requests on its origin. A path is resolved against it as a link is against a page's address. End it with `/`, so that `"orders"` lands under it. | | `optional?` | `union` | Whether a request made while signed out is sent without an `Authorization` header, rather than rejecting with `UNAUTHENTICATED`. Set `true` when one `authFetch` serves public and private endpoints. A locked client still rejects with `LOCKED`. A 401 to a request sent without a token comes back as it is. Default: `false` | | `signOutOnRepeated401?` | `union` | Whether a second 401, after a successful refresh, signs the user out. Set `false` when some endpoints answer 401 for reasons other than a rejected token, so a second 401 from them doesn't sign users out. Without a refresh token or a `refresh` option, the first 401 still does. Default: `true` | # Server (https://session-kit.pages.dev/docs/reference/server) ```ts import { … } from "@monarcode/session-kit/server"; ``` Guide: [Server sessions](https://session-kit.pages.dev/docs/server-rendering/server-sessions). ## Functions ### createServerAuth Creates the server side of Session kit ([`ServerAuth`](#serverauth)). It keeps both tokens and the user in an encrypted, `httpOnly` cookie, so page scripts never see the refresh token. ### sessionHandler Makes the handler for your session route ([`SessionHandler`](#sessionhandler)), which answers `createMirrorAuth`'s `endpoint`. A `GET` reads the session. A `POST` signs out, or gets or renews the access token for page scripts. ## Types ### ClientCredentials The access token as your server gives it to the browser, from `serverAuth.clientCredentials` in `@monarcode/session-kit/server`. `expiresIn` counts from when the server answered, so the browser's clock doesn't matter. ### CookieJar Reads and writes one request's cookies, for `createServerAuth`'s methods. The framework adapters, such as `nextJar` and `startJar`, build one. Elsewhere, wrap the request's cookies and the response's `Set-Cookie`. ### CookieJar Reads and writes one request's cookies, for `createServerAuth`'s methods. The framework adapters, such as `nextJar` and `startJar`, build one. Elsewhere, wrap the request's cookies and the response's `Set-Cookie`. Make a new jar for each request, and pass it to every call while handling that request, so they share one refresh and see each other's writes. Never keep one at module level, or one request's session would show in the next. | Prop | Type | Description | | ---------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `get` | `function` | Returns the request's cookie `name`, or `undefined` when it has none. | | `set` | `function` | Sets cookie `name` on the response. | | `delete` | `function` | Removes cookie `name`, with the attributes it was set with. | | `writable` | `union` | Whether the response can still set cookies. It's `false` where the framework can't, such as while a Next.js Server Component renders. Then nothing is refreshed or saved, and calls that would write reject, as each `serverAuth` method says. | ### CookieOptions The session cookie's attributes, as [`CookieJar`](#cookiejar)'s `set` and `delete` receive them. Every framework's cookie API takes this shape, or maps from it in a line. ### CookieOptions The session cookie's attributes, as CookieJar's `set` and `delete` receive them. Every framework's cookie API takes this shape, or maps from it in a line. | Prop | Type | Description | | ---------- | -------- | ---------------------------------------------------------------- | | `httpOnly` | `true` | Always `true`: page scripts can't read the session cookie. | | `secure` | `union` | Whether the cookie is sent over HTTPS only. | | `sameSite` | `union` | Which cross-site requests carry the cookie. | | `path` | `string` | The paths that receive the cookie. | | `domain?` | `string` | The domain that receives the cookie, or none for this host only. | | `maxAge?` | `number` | Lifetime in seconds. Absent when deleting. | ### RefreshIfDueOptions Options for [`ServerAuth`](#serverauth)'s `refreshIfDue`. ### RefreshIfDueOptions Options for ServerAuth's `refreshIfDue`. | Prop | Type | Description | | --------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `within?` | `number` | Refreshes when the access token expires within this many seconds, so it lasts through the request. Keep it well below the token's lifetime: a token that lives less than `within` is refreshed on every request. Default: `60` | ### RequestAuth One request's session: what [`ServerAuth`](#serverauth) does for a request, without its [`CookieJar`](#cookiejar). `startAuth` from `@monarcode/session-kit/tanstack-start/server` and `routerAuth` from `@monarcode/session-kit/react-router/server` give you one, and so do `readOnly()` and `writable()` from `nextSession` in `@monarcode/session-kit/next`. Elsewhere, `serverAuth.forRequest(jar)` does. ### ServerAuth The server side of Session kit, from [`createServerAuth`](#createserverauth). It keeps the session in an encrypted cookie. ### ServerAuthOptions Options for [`createServerAuth`](#createserverauth). `user`, `refresh`, `revoke`, `maxAge` and `debug` work as they do for `createAuth`. ### ServerAuthOptions Options for createServerAuth. `user`, `refresh`, `revoke`, `maxAge` and `debug` work as they do for `createAuth`. | Prop | Type | Description | | -------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | `string` | Names this auth and its cookie, `.session`, which is split into `.session.0…n` when large. Up to 64 letters, digits, `_` or `-`. | | `user` | `Src` | Where the user comes from: a schema that checks the `user` passed to `signIn`, or `fromAccessToken(…)`. The cookie is trusted once sealed, but `signIn` doesn't check its input with your backend (RequestAuth's `signIn` says what to do). | | `secret` | `union` | The key that seals the cookie, with AES-256-GCM: at least 32 random characters, kept out of your code and the browser. Pass a list to rotate keys. The first seals, and any of them opens, so sessions survive a new secret. A cookie none of them opens reads as signed out. It's checked when a method first runs, not at creation, so build tools that load your module without it, such as `next build`, still work. Without a valid secret, every method rejects with `INVALID_OPTIONS`, and a request's `state` is `unavailable`. | | `refresh?` | `RefreshFn>>` | Exchanges a refresh token for new tokens, as RefreshFn says. | | `revoke?` | `RevokeFn` | Asks your backend to revoke the tokens after `signOut` (RevokeFn). | | `maxAge?` | `number` | How long the cookie lasts, in seconds. Each save starts it again. Default: 30 days. | | `cookie?` | `SessionCookieOptions` | The session cookie's attributes (SessionCookieOptions). Default: each attribute's own. | | `clientCredentials?` | `union` | Lets your session route, or a request's `clientCredentials`, give the access token to your own page scripts. The refresh token never leaves the server. Without it, `clientCredentials` rejects with `INVALID_OPTIONS`. Default: `false` | | `debug?` | `union` | Logs what auth does on the server, like `createAuth`'s `debug` option. | ### SessionCookieOptions The session cookie's attributes: the `cookie` option of [`ServerAuthOptions`](#serverauthoptions). The cookie is always `httpOnly`. ### SessionCookieOptions The session cookie's attributes: the `cookie` option of ServerAuthOptions. The cookie is always `httpOnly`. | Prop | Type | Description | | ----------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `secure?` | `union` | Sends the cookie over HTTPS only. Turn it off only for plain-http development on `localhost`. Default: `true` | | `sameSite?` | `union` | Which cross-site requests send the cookie. `"none"` needs `secure`, or `createServerAuth` throws `INVALID_OPTIONS`. Default: `"lax"` | | `path?` | `string` | The paths the cookie is sent to. Default: `"/"` | | `domain?` | `string` | Shares the cookie with subdomains. Default: this host only. | ### SessionHandler Answers the browser client's requests to your session route, for one request and its cookie jar. [`sessionHandler`](#sessionhandler) makes one. ### SessionMiddlewareOptions Options for the middleware that refreshes before each request: `refreshSession` from `@monarcode/session-kit/tanstack-start/server` and `sessionMiddleware` from `@monarcode/session-kit/react-router/server`. ### SessionMiddlewareOptions Options for the middleware that refreshes before each request: `refreshSession` from `@monarcode/session-kit/tanstack-start/server` and `sessionMiddleware` from `@monarcode/session-kit/react-router/server`. | Prop | Type | Description | | --------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `within?` | `number` | Refreshes when the access token expires within this many seconds, as RefreshIfDueOptions explains. Default: `60` | | `cacheControl?` | `union` | The `Cache-Control` header for every response that doesn't set its own, as each one depends on who is signed in. `false` leaves every response as it is. Default: `"private, no-store"` | # TanStack Start (https://session-kit.pages.dev/docs/reference/tanstack-start) ```ts import { … } from "@monarcode/session-kit/tanstack-start"; ``` Guide: [TanStack Start](https://session-kit.pages.dev/docs/setups/tanstack-start). ## Functions ### connectSession Connects a TanStack Start router to the browser client in its context, as `auth`. Call it once in `getRouter`, after `createRouter`. ### loadSession Reads this request's session into the client. Call it in the root route's `beforeLoad`. # TanStack Start server (https://session-kit.pages.dev/docs/reference/tanstack-start-server) ```ts import { … } from "@monarcode/session-kit/tanstack-start/server"; ``` Guide: [TanStack Start](https://session-kit.pages.dev/docs/setups/tanstack-start). ## Functions ### refreshSession Refreshes the session before each request, while the response can still set the cookie. Pass it to Start's `createMiddleware().server()`, and list that middleware in `requestMiddleware` in `src/start.ts`. ### sessionRoute Makes the handlers of your session route, a server route that answers `createMirrorAuth`'s `endpoint`: the browser client signs out and gets the access token through it. Pass them as the route's `server.handlers`. ### startAuth The session of the request TanStack Start is handling (`RequestAuth`), with every `serverAuth` method and no cookie jar. Each call finds the current request, so make it once, at module level, beside `serverAuth`. ### startJar The `CookieJar` for the request TanStack Start is handling, to pass to `serverAuth` from `@monarcode/session-kit/server`. [`startAuth`](#startauth) passes it for you. Use it in a server function, request middleware, a server route, or `beforeLoad` and loaders while they run on the server. Every call during one request returns the same jar. ## Types ### StartSessionRoute The handlers [`sessionRoute`](#sessionroute) makes, for a server route's `GET` and `POST`. # Next.js (https://session-kit.pages.dev/docs/reference/next) ```ts import { … } from "@monarcode/session-kit/next"; ``` Guide: [Next.js](https://session-kit.pages.dev/docs/setups/next). ## Functions ### nextJar The `CookieJar` for `cookies()` from `next/headers`, to pass to `serverAuth` from `@monarcode/session-kit/server`. ### nextSession Gives a Next.js app its session ([`NextSession`](#nextsession)), over [`nextJar`](#nextjar) and [`proxyJar`](#proxyjar). Make it once, beside `serverAuth`, in a module only server code imports. ### proxyJar The `CookieJar` for a request in Next's Proxy (`proxy.ts`). It reads `request`'s cookies and sets new ones on `response`. Use it with `serverAuth.refreshIfDue`, so the pages that render the request see a fresh token through [`nextJar`](#nextjar). ### sessionRoute Makes the Route Handlers for your session route, which answers `createMirrorAuth`'s `endpoint`: the browser client reads the session, signs out and gets the access token through it. Pass `cookies` from `next/headers` ([`NextSessionRouteOptions`](#nextsessionrouteoptions)). ## Types ### NextCookies A request's cookie store, as `cookies()` from `next/headers` resolves it. [`nextJar`](#nextjar) reads and writes it, and Next's own store fits. ### NextJarOptions Options for [`nextJar`](#nextjar). ### NextJarOptions Options for nextJar. | Prop | Type | Description | | ----------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `writable?` | `union` | Whether the jar can set cookies. Pass `true` in a Server Function (an action) or a Route Handler. Server Components can't set cookies, so there the jar is read-only and never refreshes. Default: `false` | ### NextSession The session in a Next.js app, made by [`nextSession`](#nextsession): reads and guards for Server Components, the request's session for Server Functions and Route Handlers, and the refresh for Proxy. ### NextSessionOptions Options for [`nextSession`](#nextsession). Pass Next's own `cookies` and `redirect`, as this entry point doesn't import `next`. ### NextSessionOptions Options for nextSession. Pass Next's own `cookies` and `redirect`, as this entry point doesn't import `next`. | Prop | Type | Description | | ------------ | ---------- | --------------------------------------------------------------------------------------------------------------------------------- | | `cookies` | `function` | `cookies` from `next/headers`. | | `redirect` | `function` | `redirect` from `next/navigation`. | | `loginPath?` | `string` | Your sign-in page's path, starting with `/`. Default: `"/login"` | | `within?` | `number` | How soon before the access token expires `refresh` refreshes it, in seconds, as `serverAuth.refreshIfDue` explains. Default: `60` | ### NextSessionRoute The Route Handlers [`sessionRoute`](#sessionroute) makes, for `GET` and `POST`. ### NextSessionRouteOptions Options for [`sessionRoute`](#sessionroute). Pass Next's own `cookies`, as this entry point doesn't import `next`. ### NextSessionRouteOptions Options for sessionRoute. Pass Next's own `cookies`, as this entry point doesn't import `next`. | Prop | Type | Description | | --------- | ---------- | ------------------------------ | | `cookies` | `function` | `cookies` from `next/headers`. | ### ProxyRequest The request [`proxyJar`](#proxyjar) reads cookies from. A `NextRequest` fits. ### ProxyResponse The response [`proxyJar`](#proxyjar) sets cookies on. A `NextResponse` fits. # Expo (https://session-kit.pages.dev/docs/reference/expo) ```ts import { … } from "@monarcode/session-kit/expo"; ``` Guide: [Expo Router](https://session-kit.pages.dev/docs/setups/expo). ## Functions ### deepLinkHandler Makes `redirectSystemPath` for `app/+native-intent.tsx`. It keeps each incoming link with [`rememberDeepLink`](#rememberdeeplink), so `SessionGate` opens it after sign-in, and returns the path unchanged. ### rememberDeepLink Keeps an incoming deep link, so `SessionGate` can open it after the user signs in. Without it, `Stack.Protected` sends a signed-out user from a guarded screen to one they may see, and the link is lost. ### secureStorage Saves the session with `expo-secure-store`: in the iOS keychain, and encrypted with an Android Keystore key on Android. Pass it as `createAuth`'s `storage` in Expo apps. ## Components ### SessionGate Wraps the root navigator in `app/_layout.tsx`. It adds what Expo Router's `Stack.Protected` lacks: a splash screen held while the session is restored, a screen for when access is unavailable, and deep links kept for after sign-in. ## Types ### DeepLinkHandler The `redirectSystemPath` that [`deepLinkHandler`](#deeplinkhandler) makes, for `app/+native-intent.tsx`. ### RememberDeepLinkOptions Options for [`rememberDeepLink`](#rememberdeeplink) and [`deepLinkHandler`](#deeplinkhandler), the same as `safeReturnTo`'s. Set `loginPath` when your sign-in screen isn't `/login`, so links to it are dropped rather than kept. ### RememberDeepLinkOptions Options for rememberDeepLink and deepLinkHandler, the same as `safeReturnTo`'s. Set `loginPath` when your sign-in screen isn't `/login`, so links to it are dropped rather than kept. | Prop | Type | Description | | ------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `loginPath?` | `union` | Your sign-in page's path, or several. A path to it is replaced with `"/"`, so signing in never sends the user back to sign in. Matched by path, ignoring case, percent-encoding, trailing slashes and any query. Default: `"/login"` | ### SecureStorageOptions Options for [`secureStorage`](#securestorage). Both are for iOS: when the keychain may be read, and what happens to a session an earlier install left there. ### SecureStorageOptions Options for secureStorage. Both are for iOS: when the keychain may be read, and what happens to a session an earlier install left there. | Prop | Type | Description | | --------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `keychainAccessible?` | `number` | When iOS lets the app read the session: one of SecureStore's constants, such as `AFTER_FIRST_UNLOCK`. An app that refreshes in a background task while the device is locked needs `AFTER_FIRST_UNLOCK`. Passed to SecureStore unchanged. While the keychain can't be read, the session is `unavailable`, not signed out. Default: `WHEN_UNLOCKED` | | `clearOnReinstall?` | `union` | Whether a reinstalled app starts signed out. iOS keeps keychain items after the app is deleted, so a session an earlier install left is removed before it's read. `false` restores it instead. Default: `true` | ### SessionGateProps Props for [`SessionGate`](#sessiongate): the root navigator, what to show while access is unavailable, and what else the splash screen waits for. ### SessionGateProps Props for SessionGate: the root navigator, what to show while access is unavailable, and what else the splash screen waits for. | Prop | Type | Description | | -------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `children?` | `ReactNode` | The root navigator, usually a `Stack` guarded with `Stack.Protected`. | | `unavailable?` | `function` | Shown instead of the navigator while access is unavailable, such as after a failed refresh. Without it, the error is thrown to the root layout's `ErrorBoundary`. That boundary's `retry` only renders again, and a failed refresh stays failed until `auth.retry()`, so call both. | | `client?` | `RetryableSource` | The client to watch. Default: the nearest `AuthProvider`'s. | | `ready?` | `union` | Whether the rest of the app is ready for its first screen, such as fonts or remote config. Until it's `true`, and while the session is restored, the gate renders nothing and keeps the splash screen up. It only holds the splash screen the first time, even if the gate remounts. Setting it `false` later changes nothing on screen, but the deep link opened after a sign-in still waits for it. Default: `true` | ### UnavailableProps What the `unavailable` render prop of an adapter component, such as React Router's `SessionOutlet` or Expo's `SessionGate`, receives. ### UnavailableProps What the `unavailable` render prop of an adapter component, such as React Router's `SessionOutlet` or Expo's `SessionGate`, receives. | Prop | Type | Description | | ------- | ---------- | ------------------------------------------------------------------------------- | | `error` | `union` | Why access is unavailable, such as a failed refresh or a locked keychain. | | `retry` | `function` | Tries again, as `auth.retry()` does. A repeated failure stays in `state.error`. | # Effect (https://session-kit.pages.dev/docs/reference/effect) ```ts import { … } from "@monarcode/session-kit/effect"; ``` Guide: [Effect and effetch](https://session-kit.pages.dev/docs/effect). ## Classes ### Auth Your auth client as an Effect service. Its Effects read the session, get an access token for API calls, sign in and out, update the user, and stream state changes. ### AuthFailure How `Auth`'s Effects fail: a tagged error that carries the client's `AuthError`. Handle it with `Effect.catchTag("AuthFailure", …)` and match on `code`. The original error is the `cause`. ## Types ### AuthFailureCode The codes an [`AuthFailure`](#authfailure) carries: every `AuthErrorCode` except `INVALID_OPTIONS`, which is misuse and so a defect. ### AuthLayerClient What `Auth.layer` takes. Once you register a client in `Register`, that's your client, or on the server a request's session with the same user (`RequestAuth`). Until then, it's any [`AuthServiceClient`](#authserviceclient). ### AuthServiceClient What `Auth` needs from a client. Any `createAuth` or `createMirrorAuth` client fits, and so does a fake with these members in tests. `signIn` is optional, because a mirror made without it has none. ### RegisteredSession The session `Auth.session` gives you: your registered client's `Session`, never `null`. ### RegisteredSignIn What `Auth.signIn` takes: your registered client's `signIn` input. It's `never` for a client without `signIn`. ### RegisteredState Your registered client's `AuthState`, as `Auth.changes` emits it. ### RegisteredUserInput What `Auth.updateUser` takes: your registered client's user input. It's `never` for a client without `updateUser`, such as one whose user comes from the access token. # effetch (https://session-kit.pages.dev/docs/reference/effetch) ```ts import { … } from "@monarcode/session-kit/effetch"; ``` Guide: [Effect and effetch](https://session-kit.pages.dev/docs/effect). ## Functions ### sessionBearer An effetch plugin that sends the current access token as `Authorization: Bearer`. After a 401, it gets a new access token once. `GET` and `HEAD` requests are then sent again; other requests return the 401 for you to retry. If the request sent again gets a 401 too, the user is signed out, unless `signOutOnRepeated401` is off. If the renewal ends the session, the call returns the 401. ## Types ### SessionBearerOptions Options for [`sessionBearer`](#sessionbearer). ### SessionBearerOptions Options for sessionBearer. | Prop | Type | Description | | ----------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `baseUrl` | `string` | Your API's URL, usually the effetch client's `baseUrl`. It must be absolute, such as `https://api.example.com`. The access token goes only to requests on its origin. | | `optional?` | `union` | Whether a call made while signed out is sent without an `Authorization` header, rather than failing with `UNAUTHENTICATED`. Set `true` when one client serves public and private endpoints. A locked client still fails with `LOCKED`. Calls can override it. Default: `false` | | `signOutOnRepeated401?` | `union` | Whether a second 401, after a successful refresh, signs the user out. Set `false` when some endpoints answer 401 for reasons other than a rejected token. Calls can override it. Default: `true` | ### SessionBearerPlugin The plugin [`sessionBearer`](#sessionbearer) returns. Its calls can fail with `AuthFailure`, and `Fetch.make` needs the `Auth` service. # Error codes (https://session-kit.pages.dev/docs/reference/error-codes) Every error from Session kit's own logic is an `AuthError`. Match on its `code`. The `message` is for people, and may change. ```ts title="src/error-codes.ts" import { AuthError } from "@monarcode/session-kit"; export function isLocked(error: unknown): boolean { return error instanceof AuthError && error.code === "LOCKED"; } ``` How errors reach your app, and how to show them: [Errors](https://session-kit.pages.dev/docs/configuration/errors). ## USER_VALIDATION_FAILED The user couldn't be accepted. `issues` holds the schema's issues when it rejected the user, and `cause` holds the error when something else failed, such as an `updateUser` function. Otherwise `message` says why, such as a user that isn't a plain JSON object. ## INVALID_SESSION Tokens or a saved session that can't be used, such as an access token that already expired at sign-in, or a saved session that's malformed. In the browser of a server-rendered app, your server answered with something other than a session or an access token. ## PERSISTENCE_FAILED Storage couldn't be read, written or cleared, or `locking.seal` failed. In the browser of a server-rendered app, signing out on the server failed. ## REFRESH_FAILED The refresh failed: your refresh callback threw or timed out, or there was no `refresh` option or refresh token to use. In the browser of a server-rendered app, reading the session or the access token from the server failed. On the server, the access token needed a refresh in a request that can't set cookies. ## REVOKE_FAILED Your `revoke` callback failed. It shows in `state.error`, and `signOut()` still resolves. ## SESSION_CHANGED The session changed while the call ran: a newer sign-in, a sign-out, or another tab replaced it. ## UNAUTHENTICATED The call needs someone signed in, and no one is. ## LOCKED The session is locked. Call `unlock()` first. ## UNLOCK_FAILED `locking.open` failed, so the session stays locked. ## INVALID_OPTIONS An option or argument Session kit can't use, or a method called without the option it needs or where it can't work, such as a server `signIn` in a request that can't set cookies. # Compatibility (https://session-kit.pages.dev/docs/reference/compatibility) Check this page before you upgrade a framework that Session kit works with, or to see whether your runtime is supported. ## Peer dependencies Every peer dependency is optional. Install only the ones the entry points you import use: | Peer | Range | Used by | | ------------------------ | ----------------------- | ------------------------------------------------------ | | `react` | `^18.0.0 \|\| ^19.0.0` | `/react`, `/tanstack-router`, `/react-router`, `/expo` | | `@tanstack/react-router` | `^1.132.0` | `/tanstack-router` | | `@tanstack/react-start` | `^1.169.0` | `/tanstack-start/server` | | `react-router` | `^7.0.0 \|\| ^8.0.0` | `/react-router`, `/react-router/server` | | `next` | `^16.0.0` | `/next` | | `effect` | `^4.1.0` | `/effect`, `/effetch` | | `@monarcode/effetch` | `^0.1.0-rc.2` | `/effetch` | | `expo-router` | `^5.0.2` through `^58` | `/expo` | | `expo-secure-store` | `^14.2.0` through `^58` | `/expo` | | `expo-file-system` | `^18.1.0` through `^58` | `/expo` | | `react-native` | `>=0.79.0` | `/expo` | Three things to know about these peers: - **Expo.** The Expo ranges cover Expo SDK 53 and newer. Install them with `npx expo install`, so that they match your SDK, as the [Expo guide](https://session-kit.pages.dev/docs/setups/expo#installation) shows. - **React Router with server rendering.** `/react-router/server` needs React Router's middleware: React Router 8, or 7.9 and later with `future.v8_middleware`. - **React DOM.** No entry point imports React DOM. ## Tested versions CI tests the oldest and the newest version in each range, with one exception: it tests `expo-file-system` only at its newest. Version 18.1 publishes no `exports` map, so its types don't resolve under NodeNext. ## Runtimes Session kit runs in browsers, in React Native (Expo SDK 53 and newer, with Hermes and Metro), and in Node.js 20 and newer. Your framework can need a newer Node.js: React Router 8 needs 22.22. Session kit uses these platform features: - **`AbortController`** is required. - **`crypto.randomUUID`** makes session IDs when present. Without it, the client uses `crypto.getRandomValues`, or `Math.random` as a last resort. - **Web Locks** keep tabs to one refresh at a time when present. Without them, two tabs can refresh at the same instant, as [Tabs](https://session-kit.pages.dev/docs/configuration/tabs) explains. - **`BroadcastChannel`** lets a `createMirrorAuth` client follow sign-ins and sign-outs in other tabs, when present. - **Web Crypto** is required on the server by `@monarcode/session-kit/server`, as in Node.js 20 and newer. ## TypeScript The package is ESM-only. It's checked with TypeScript 6.0.3 under NodeNext and Bundler resolution, with `exactOptionalPropertyTypes` both on and off. # Common questions (https://session-kit.pages.dev/docs/faq) These are short answers to questions that come up when you adopt Session kit. Each links to the page with the full answer. ## Do I need a refresh token? No. Without a refresh token or a `refresh` callback, the user is signed out when the access token expires or your API rejects it. ## What if my backend doesn't say when the token expires? The client still works. See [Expiry](https://session-kit.pages.dev/docs/configuration/expiry) for where the client looks for the expiry, and what it does when it finds none. ## Is it safe to keep tokens in `localStorage`? Any script on your origin can read them, as [Security](https://session-kit.pages.dev/docs/security) explains. If that isn't acceptable, keep the session on the server with [server rendering](https://session-kit.pages.dev/docs/server-rendering). ## How do I use it with TanStack Query, or another cache? Put [`state.sessionId`](https://session-kit.pages.dev/docs/client/state) in the query key, or clear the cache when it changes, so one account never reads another's data. With effetch-query, [`resetKey`](https://session-kit.pages.dev/docs/effect#cache-with-effetch-query) does it. ## Why does TanStack Start in SPA mode flash my pending component? The build prerenders an HTML shell, which the browser shows before any JavaScript runs. The client restores the session as soon as the script loads. Plain TanStack Router shows a blank page for the same moment. Give the shell a neutral pending component, as [TanStack Start in SPA mode](https://session-kit.pages.dev/docs/setups/tanstack-router#tanstack-start-in-spa-mode) does. ## What if some endpoints answer 401 for other reasons? Set `signOutOnRepeated401: false` in `createAuthFetch`'s options, beside `baseUrl`, so that a second 401 from those endpoints, after a refresh, doesn't sign users out. Without a refresh token or a `refresh` callback, any 401 still signs the user out. See [Authenticated requests](https://session-kit.pages.dev/docs/client/requests). ## How much does it add to my bundle? About 11 kB gzipped for a single-page app with a router adapter and `createAuthFetch`. With server rendering, where the session work happens on the server, it's about 5 kB. The routers you don't use add nothing: see [Entry points](https://session-kit.pages.dev/docs/reference). ## Can I sign users in with Google, or a magic link? Yes, through your backend. Whatever your backend does to sign a user in, Session kit starts from the tokens it answers with. See [What it doesn't do](https://session-kit.pages.dev/docs#what-it-doesnt-do). # Security (https://session-kit.pages.dev/docs/security) Session kit saves the tokens your backend issues and manages the session around them. This page explains what stays your backend's job, where tokens live in each setup, and where the client sends them. ## Your backend decides what's allowed Your backend must authenticate every request and authorize private operations. Route guards, screen guards and the user's fields in the client decide what to show, not what's allowed. With server rendering, `signIn` seals what it's given. If the browser signs in with your backend and sends the tokens and the user, the cookie's user is what the browser sent, until a refresh or `updateUser` replaces it. Use it to show who is signed in. Let your backend authorize requests with the access token, or check the tokens before `signIn`. ## Where tokens live Each setup saves the tokens in a different place: | Setup | Where the tokens live | | ------------------------------------------ | ----------------------------------------------------------- | | Browser app | `localStorage` or `sessionStorage`, through `webStorage()` | | [Server rendering](https://session-kit.pages.dev/docs/server-rendering) | An encrypted `httpOnly` cookie | | [Expo app](https://session-kit.pages.dev/docs/setups/expo) | The iOS keychain, or encrypted with an Android Keystore key | Each place protects the tokens differently: - **In a browser app**, any JavaScript on your origin can read the tokens, as with any credential a browser saves. They never go in cookies, so the browser doesn't send them on its own. - **With server rendering**, page scripts can't read the cookie. Only the access token reaches page scripts, and only with `clientCredentials`. - **In an Expo app**, the `expo-secure-store` config plugin keeps the session out of Android backups. `secureStorage()` removes a session that an earlier install left in the iOS keychain. ## The session route With server rendering, the browser's client signs out and gets the access token through your session route, and reads the session there in most setups. `sessionHandler` from `@monarcode/session-kit/server` answers it, through each framework's `sessionRoute`. It answers your own site's pages only, so another site can't sign the user out or ask for the access token. It makes two checks, and answers 403 to a request that fails one: - A `Sec-Fetch-Site` header, when the request has one, must be `same-origin`. Browsers send it with requests to https and localhost sites. - On plain http, where browsers don't send that header, a `POST` must have an `Origin` header that matches the request's URL. A `GET` passes: it changes nothing, and another site can't read the answer. Signing in stays your own server code. A route of your own that changes the session must refuse other sites too, where the framework doesn't check. Make the same two checks there. ## Where tokens go [`createAuthFetch`](https://session-kit.pages.dev/docs/client/requests) and the effetch plugin [`sessionBearer`](https://session-kit.pages.dev/docs/effect#6-call-your-api-with-effetch) send the access token only to your API's origin: - A request to another origin, or with a username or password in its URL, fails with `INVALID_OPTIONS` before anything is sent. - A redirect fails rather than carrying the token on. [Debug events](https://session-kit.pages.dev/docs/configuration/debugging) leave out tokens unless you turn on `sensitive`. ## Keep tokens out of page data Sessions, the auth state, route context and the loader results Session kit makes hold no tokens: a `Session` has the user and its `sessionId` only. Don't put tokens in loader results or hydrated page data yourself.