Skip to Content
SDK v3newAuthenticationSession management

Session management

After a successful sign-in (by any method — email, OTP, OAuth, anonymous), AuthClient stores AuthSession locally and refreshes the access token on a timer. This page covers how to read state, react to changes, sign out, and integrate the session into code outside BillingClient.

AuthSession

interface AuthSession { access_token: string; refresh_token: string; /** Absolute timestamp in ms (Date.now()-comparable). */ expires_at: number; user: { id: string; /** null for anonymous users (signInAnonymously). */ email: string | null; country?: string | null; is_anonymous?: boolean; }; }

Default access token lifetime is 1 hour, refresh token — 30 days of inactivity (configured platform-side). The SDK abstracts this away: getAccessToken() refreshes when expiry is within 60s.

Reading state

Snapshot with no network or await. Returns the current session or null.

const session = auth.getCachedSession(); const user = auth.getCachedUser(); // shortcut: session?.user

When to use: for conditional UI (“Sign in” vs “Profile”). Don’t use for backend requests — that needs getAccessToken(), which can refresh.

Until await auth.ready() the method may return null even when a session is in storage. Hydration is async (especially chrome.storage.local), and the SDK doesn’t block the constructor.

Listening for changes

onAuthChange fires on every session change. The listener receives two arguments: a discriminated event and the session (or null).

type AuthChangeEvent = | 'INITIAL_SESSION' | 'SIGNED_IN' | 'SIGNED_OUT' | 'TOKEN_REFRESHED' | 'USER_UPDATED' | 'PASSWORD_RECOVERY'; const unsubscribe = auth.onAuthChange((event, session) => { if (event === 'SIGNED_IN') { console.log('logged in as', session?.user.email); showAppShell(); } else if (event === 'SIGNED_OUT') { showLoginScreen(); } }); // on component unmount: unsubscribe();

Which event fires for what

EventWhen
INITIAL_SESSIONAlways the first callback for a new subscription — after hydration from storage. session may be null (not signed in) or a restored session.
SIGNED_INsignInWithEmail, signUp (signed_in), verifyOtp (type ≠ recovery), signInWithOAuth, signInAnonymously, cross-context login.
SIGNED_OUTsignOut, revokeAllSessions, refresh returned 401, removal from another context.
TOKEN_REFRESHEDrefresh() updated the tokens, same user.id. Cross-context rotation.
USER_UPDATEDupgradeAnonymousToEmail — same user.id, updated email/is_anonymous.
PASSWORD_RECOVERYverifyOtp({ type: 'recovery' }) — short-lived session for updatePassword. UI should show “set new password”, not the regular signed-in flow.

Initial snapshot. Every subscriber gets a first callback with event = 'INITIAL_SESSION' via a microtask after hydration resolves, even if there’s no session yet (callback receives null). All subsequent callbacks are real transitions.

That lets you safely side-effect on event === 'SIGNED_IN' (e.g. force-refetch) without confusing it with the page-reload restore.

signOut

Clears local state IMMEDIATELY — no waiting for the network. Then best-effort POST to the server to revoke the refresh token.

await auth.signOut(); // auth.getCachedSession() === null // onAuthChange fired with event='SIGNED_OUT', session=null // storage cleared

Never throws. Even on server 5xx or a dead network, local logout has already happened. Safe to call inside try/finally without your own error handler.

Logout button

function LogoutButton() { return <button onClick={() => auth.signOut()}>Sign out</button>; }

No await needed — the UI updates via onAuthChange without waiting for the network round-trip.

refresh

You usually don’t call this manuallygetAccessToken refreshes itself. The method is useful for rare cases:

  • Force a refresh after a backend operation that changed claims. E.g. the user upgraded to admin and you want new roles in the access token immediately, not in an hour.
  • Tests. A forced refresh simplifies endpoint unit tests.
auth.refresh(): Promise<AuthSession | null>

Returns the new session or null if refresh got a 401 (refresh token revoked). A network error / 5xx — propagates; the session stays; we don’t sign the user out for transient issues.

Deduplication

Parallel getAccessToken() + refresh() calls on the same AuthClient share one inflight request. Important if you fire several API calls in parallel near expiry:

// One refresh request for all three, not three parallel. const [users, prices, offers] = await Promise.all([ fetchUsers(), fetchPrices(), fetchOffers() ]);

Revoked sessions: automatic retry

A session can die server-side while still looking fresh locally: a refresh-rotation race in another context (two extension pages refreshing with the same rotating token — the auth backend revokes the whole session family), revokeAllSessions() from another device, or clock skew. The cached access token then passes the local expiry check, but every backend call answers 401.

BillingClient and ApiGatewayClient recover from this automatically: on a 401 from a request that carried a Bearer, they force one refresh() and replay the request with the new token. If the refresh itself gets a 401 (the family is revoked), the session is cleared, onAuthChange fires SIGNED_OUT, and the original error surfaces as PaywallError with code: 'invalid_token' — see Error codes. PaywallUI goes one step further: on a preauth paywall a checkout that dies this way reopens the auth gate with the checkout still pending — after re-signin the purchase resumes, and an already-paid user lands on the restored success view instead of being charged twice.

Exactly one replay — a second 401 means the fresh token is also rejected, so retrying can’t fix it. Stream bodies through ApiGatewayClient are never replayed (the first attempt consumes the stream).

getAccessToken: scenarios

StateBehaviourReturns
No sessionNo networknull
Session present, >60s to expiryNo network — returns the current tokenaccess_token
Session present, <60s to expiryLazy refresh on the servernew access_token
Refresh got 401Clears the session, emits logoutnull
Refresh got a network/5xx errorReturns the current token (still valid)old access_token

Usage without BillingClient

If you use SDK 3.0 only for AuthClient (e.g. your own UI, not our PaywallUI) — everything works standalone. Just attach the token to your requests:

import { AuthClient } from '@monetize.software/sdk/core'; const auth = new AuthClient({ paywallId: 'pw_123' }); await auth.ready(); async function callMyApi(path: string) { const token = await auth.getAccessToken(); const res = await fetch(`https://api.myapp.com${path}`, { headers: token ? { Authorization: `Bearer ${token}` } : {} }); if (res.status === 401) { // The token could have been revoked between getAccessToken and the request — // try refresh and retry. BillingClient/ApiGatewayClient do exactly this // automatically; a hand-rolled fetch needs it explicitly. const fresh = await auth.refresh(); if (!fresh) { redirectToLogin(); return; } return callMyApi(path); } return res.json(); }

Persistence

The session is stored under the key pw-<paywallId>-auth-v1 in StorageAdapter. By default the SDK picks:

  • Chrome Extensionchrome.storage.local (visible across all extension contexts).
  • Webwindow.localStorage.
  • Otherwise (Node.js, tests) — in-memory map.

You can pass your own adapter — e.g. for encrypted storage or sessionStorage:

import { AuthClient, type StorageAdapter } from '@monetize.software/sdk/core'; const sessionStorageAdapter: StorageAdapter = { async getItem(k) { return window.sessionStorage.getItem(k); }, async setItem(k, v) { window.sessionStorage.setItem(k, v); }, async removeItem(k) { window.sessionStorage.removeItem(k); } }; const auth = new AuthClient({ paywallId: 'pw_123', storage: sessionStorageAdapter });

Replacing storage means the session won’t survive a reload (with sessionStorage). Acceptable trade-off for private cases, but don’t do it by default — frequent re-logins annoy users.

Cross-context sync (multi-tab + Chrome Extension)

AuthClient listens for changes on the session key from other contexts and syncs state automatically — no manual reload or chrome.storage.onChanged plumbing required.

  • Web (multi-tab): native storage event — sign in on one tab, others emit onAuthChange instantly with the new session, getCachedSession returns it, getAccessToken yields the fresh Bearer. Logout (another context called signOut()) also propagates — the current context signs out without a reload.
  • Chrome Extension: via chrome.storage.onChanged. One login in the popup → background, content script (with storage permission), and the options page all receive onAuthChange at once.
// popup.tsx const auth = new AuthClient({ paywallId: 'pw_123' }); const paywall = new PaywallUI({ paywallId: 'pw_123', auth }); paywall.openAuth(); // user signs in — token lands in chrome.storage.local
// background.ts import { AuthClient, BillingClient } from '@monetize.software/sdk/core'; const auth = new AuthClient({ paywallId: 'pw_123' }); auth.onAuthChange((event, session) => { if (event === 'SIGNED_IN') { // Fires right after popup signIn } }); const billing = new BillingClient({ paywallId: 'pw_123', auth }); const gateway = billing.createApiGatewayClient(); // gateway.call() will use the same Bearer the popup signed in with.

Custom StorageAdapter

Replacing storage is supported, but if your adapter is custom — implement watch?(key, cb), otherwise cross-context sync won’t work (the memory fallback and custom adapters without watch are still OK, just no live updates):

const myStorage: StorageAdapter = { async getItem(k) { /* ... */ }, async setItem(k, v) { /* ... */ }, async removeItem(k) { /* ... */ }, watch(key, cb) { // return unsubscribe; cb(null) on removal, cb(string) on update const unsub = mySource.on(key, cb); return unsub; } };

Teardown

On hot reload, tests or explicit reinitialization — call destroy(). Otherwise storage listeners outlive the instance:

auth.destroy(); // unsubscribes storage.watch, clears onAuthChange listeners

In a content script, Bearer works the same way if the manifest has storage permission. To isolate the token from the content script (page is untrusted) — have the content script talk to background via chrome.runtime.sendMessage and let only the background do Bearer-authenticated fetches.

Next steps

  • Error codes — list of PaywallError.code for every auth method.
  • BillingClient — how AuthClient integrates with billing automatically.
  • Storage adaptersStorageAdapter details and built-in implementations.