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() ]);

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 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.