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
Sync — getCachedSession
Snapshot with no network or await. Returns the current session or null.
const session = auth.getCachedSession();
const user = auth.getCachedUser(); // shortcut: session?.userWhen 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
| Event | When |
|---|---|
INITIAL_SESSION | Always the first callback for a new subscription — after hydration from storage. session may be null (not signed in) or a restored session. |
SIGNED_IN | signInWithEmail, signUp (signed_in), verifyOtp (type ≠ recovery), signInWithOAuth, signInAnonymously, cross-context login. |
SIGNED_OUT | signOut, revokeAllSessions, refresh returned 401, removal from another context. |
TOKEN_REFRESHED | refresh() updated the tokens, same user.id. Cross-context rotation. |
USER_UPDATED | upgradeAnonymousToEmail — same user.id, updated email/is_anonymous. |
PASSWORD_RECOVERY | verifyOtp({ 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 clearedNever 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 manually — getAccessToken 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
| State | Behaviour | Returns |
|---|---|---|
| No session | No network | null |
| Session present, >60s to expiry | No network — returns the current token | access_token |
| Session present, <60s to expiry | Lazy refresh on the server | new access_token |
| Refresh got 401 | Clears the session, emits logout | null |
| Refresh got a network/5xx error | Returns 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 Extension —
chrome.storage.local(visible across all extension contexts). - Web —
window.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
storageevent — sign in on one tab, others emitonAuthChangeinstantly with the new session,getCachedSessionreturns it,getAccessTokenyields the fresh Bearer. Logout (another context calledsignOut()) also propagates — the current context signs out without a reload. - Chrome Extension: via
chrome.storage.onChanged. One login in the popup → background, content script (withstoragepermission), and the options page all receiveonAuthChangeat 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 listenersIn 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.codefor every auth method. - BillingClient — how
AuthClientintegrates with billing automatically. - Storage adapters —
StorageAdapterdetails and built-in implementations.