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