Storage adapters
StorageAdapter is the single interface to persistent storage. The SDK writes everything that must survive a reload through it: auth session, bootstrap cache, balances, visitor id, trial counter. By default the SDK picks an implementation that matches the runtime — usually you don’t need to configure anything. This page covers when it’s worth supplying your own adapter and how to do it right.
Auto-detection
createStorage() without arguments picks by priority:
| Runtime | Implementation | Cross-context sync |
|---|---|---|
Chrome Extension (has chrome.runtime.id) | chrome.storage.local | ✅ chrome.storage.onChanged — popup ↔ background ↔ content scripts in every tab ↔ options page. |
Browser (has window.localStorage) | window.localStorage | ✅ Native storage event — multi-tab same-origin. |
| Node / Deno / Bun / Edge / tests | In-memory Map | ❌ Current process only. Reload = logout. |
Detection runs inside every SDK client’s constructor. If you want a single adapter across AuthClient + BillingClient, create it once and pass it explicitly via opts.storage.
Interface
interface StorageAdapter {
getItem(key: string): Promise<string | null>;
setItem(key: string, value: string): Promise<void>;
removeItem(key: string): Promise<void>;
/**
* Optional. Subscription to external changes of `key` (another tab,
* background context). Returns an unsubscribe function.
*
* - `value`: new value or `null` (deleted / absent).
* - Fires ONLY for cross-context changes; calling `cb` for your own
* setItem/removeItem is NOT required.
*/
watch?(key: string, cb: (value: string | null) => void): () => void;
}Every method is async. This is required for chrome.storage.local (callback API wrapped in Promise) and for server-side adapters (Redis, KV).
watch is optional. Without it, cross-context sync does not work — sign-in in one tab won’t propagate to another automatically. The memory fallback has no watch by design.
Keys
All keys used by the SDK are exported from STORAGE_KEYS:
import { STORAGE_KEYS } from '@monetize.software/sdk/core';| Key | Contents | Identity-bound |
|---|---|---|
pw-visitor-id | UUID v4 — stable visitor identifier for analytics. Not PII. | — |
pw-{paywallId}-auth-v1 | AuthSession (access_token, refresh_token, expires_at, user). Source of truth for login. | — |
pw-{paywallId}-anon-rt-v1 | Refresh token of the last anonymous user. Survives signOut so the next signInAnonymously picks up the same anon user. Cleared by signOut({forgetAnonymous: true}) or by a 401 from refresh. | — |
pw-{paywallId}-bootstrap-v1 | Persisted bootstrap (settings/prices/offers/layout/locales/version). 1h TTL. | — |
pw-{paywallId}-{identityKey}-user-v1 | Last-known PaywallUser — offline fallback. Key is tied to identity so another user’s state doesn’t leak after a switch. | ✅ |
pw-{paywallId}-{identityKey}-balances-v1 | Persisted balances (AI providers × tokenization queries). 5min TTL. | ✅ |
pw-{paywallId}-last-login-method | 'email' | 'google' | 'apple' | 'github' | 'facebook' — what the user picked last time. For UI prefill. | — |
pw-{paywallId}-last-login-email | User’s email for form prefill. | — |
pw-trial-* | Trial counter (firstOpenAt / skipTimes). Shape depends on the paywall’s trial mode. | — |
identityKey is a stable hash of the current identity (Bearer user.id, email, or anonymousId). On setIdentity the key changes — the SDK unsubscribes from the old watch and re-subscribes under the new one, so state from the previous identity doesn’t leak.
When to swap the adapter
In most cases — don’t. Swap when one of these applies:
| Case | What to supply |
|---|---|
| High-security / enterprise — tokens must not survive a reload | In-memory or sessionStorage |
| Server-side / Edge runtime (Node, Cloudflare Workers, Deno Deploy) | Redis / KV / Durable Object |
| At-rest encryption (PII in LocalStorage forbidden by policy) | Custom adapter with symmetric encryption |
In-memory adapter
The session will not survive a reload — the user will sign in again on every page load. For private/high-security environments this is an intentional trade-off.
import { AuthClient, BillingClient, type StorageAdapter } from '@monetize.software/sdk/core';
const memoryStorage: StorageAdapter = (() => {
const m = new Map<string, string>();
return {
async getItem(k) { return m.get(k) ?? null; },
async setItem(k, v) { m.set(k, v); },
async removeItem(k) { m.delete(k); }
};
})();
// One shared adapter across both clients — otherwise visitor_id will desync.
const auth = new AuthClient({ paywallId: 'pw_123', apiOrigin: '...', storage: memoryStorage });
const billing = new BillingClient({ paywallId: 'pw_123', apiOrigin: '...', auth, storage: memoryStorage });sessionStorage adapter
Session lives until the tab is closed; a reload in the same tab survives, a new tab does not.
const sessionAdapter: 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); }
// No watch — sessionStorage isn't shared across tabs, so there's nothing to watch.
};Without watch, cross-context sync is off. Multi-tab UX will be broken: sign-in in one tab won’t reflect in another. For sessionStorage that’s fine (each tab is isolated by design), for custom backend adapters it isn’t.
Encrypted adapter (web)
Symmetric encryption with a key that itself doesn’t live in storage. Protects against trivial DevTools peeking, but not against XSS (if attacker runs JS, they grab the key too).
async function encrypt(plaintext: string, key: CryptoKey): Promise<string> {
const iv = crypto.getRandomValues(new Uint8Array(12));
const data = new TextEncoder().encode(plaintext);
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data);
const combined = new Uint8Array(iv.length + ciphertext.byteLength);
combined.set(iv);
combined.set(new Uint8Array(ciphertext), iv.length);
return btoa(String.fromCharCode(...combined));
}
async function decrypt(encrypted: string, key: CryptoKey): Promise<string> {
const combined = Uint8Array.from(atob(encrypted), (c) => c.charCodeAt(0));
const iv = combined.slice(0, 12);
const ciphertext = combined.slice(12);
const data = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext);
return new TextDecoder().decode(data);
}
function makeEncryptedAdapter(key: CryptoKey): StorageAdapter {
return {
async getItem(k) {
const raw = window.localStorage.getItem(k);
if (!raw) return null;
try { return await decrypt(raw, key); } catch { return null; }
},
async setItem(k, v) {
window.localStorage.setItem(k, await encrypt(v, key));
},
async removeItem(k) {
window.localStorage.removeItem(k);
},
watch(k, cb) {
const handler = async (e: StorageEvent) => {
if (e.key !== k || e.storageArea !== window.localStorage) return;
if (e.newValue == null) return cb(null);
try { cb(await decrypt(e.newValue, key)); } catch { cb(null); }
};
window.addEventListener('storage', handler);
return () => window.removeEventListener('storage', handler);
}
};
}Encryption adds an async hop on every read/write. With frequent calls (getAccessToken() on the hot path) it’s measurable. Benchmark before shipping.
Server-side: Redis / KV
For headless setups (Node/Bun/Edge) where the SDK lives on your backend, you almost always need a per-user adapter — otherwise pw-{paywallId}-auth-v1 will be shared across every user in the process.
import type { StorageAdapter } from '@monetize.software/sdk/core';
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
function makeUserStorage(userId: string): StorageAdapter {
const prefix = `monetize:${userId}:`;
return {
async getItem(k) {
return (await redis.get(prefix + k)) ?? null;
},
async setItem(k, v) {
// 30 day TTL — aligned with refresh-token expiry. The SDK updates
// expires_at in the payload itself; redis TTL guarantees cleanup of
// orphaned keys.
await redis.set(prefix + k, v, 'EX', 30 * 24 * 3600);
},
async removeItem(k) {
await redis.del(prefix + k);
}
// watch is usually unnecessary on the server — every worker has its own instance.
};
}
// On a handler call:
const storage = makeUserStorage(req.user.id);
const auth = new AuthClient({ paywallId, apiOrigin, storage });Cloudflare Workers — same idea, via env.MY_KV.get/put/delete with a per-user prefix.
Persist-per-process vs persist-per-user. With a long-running worker the Redis adapter is mandatory (otherwise all users would overwrite each other’s tokens). For a serverless function (Lambda, edge function) that lives for one request, in-memory is fine — tokens just get re-fetched via refresh on each request.
Cross-context: watch contract
watch(key, cb) contract:
- Fires only on cross-context changes. Don’t call
cbfor your ownsetItem(otherwise you get a loop). value: string | null—nullmeans either “deleted” or “never existed”.unsubscribeis returned synchronously. No promises.
Native implementations:
// web/localStorage:
watch(key, cb) {
const handler = (e: StorageEvent) => {
if (e.storageArea !== window.localStorage) return;
if (e.key !== key) return;
cb(e.newValue); // null when another tab removeItem'd
};
window.addEventListener('storage', handler);
return () => window.removeEventListener('storage', handler);
}// chrome.storage.local:
watch(key, cb) {
const handler = (changes, area) => {
if (area !== 'local') return;
const ch = changes[key];
if (!ch) return;
cb(typeof ch.newValue === 'string' ? ch.newValue : null);
};
chrome.storage.onChanged.addListener(handler);
return () => chrome.storage.onChanged.removeListener(handler);
}What NOT to do
- Don’t store the session in cookies. SDK 3.0 is intentionally cookie-free — it works in Chrome extensions, Telegram Mini Apps, and partitioned storage environments. Cookies don’t.
- Don’t write your own
lastLoginEmailoverpw-{paywallId}-last-login-email. The SDK will overwrite it. - Don’t double-prefix keys in your adapter. The SDK already prefixes with
pw-andpaywallId. Double prefixing is a migration bug. - Don’t add
watchto an in-memory adapter. Cross-context sync requires multiple processes; for a single processwatchis meaningless and only breaks unit tests.
Utilities
import { createStorage, ensureVisitorId, generateVisitorId, STORAGE_KEYS } from '@monetize.software/sdk/core';
// Build an adapter with auto-detection (or return what you pass in):
const storage = createStorage(/* override?: StorageAdapter */);
// Resolve visitor_id (reads from storage, generates and saves if missing):
const visitorId = await ensureVisitorId(storage);
// Generate a fresh UUID v4 (for tests):
const id = generateVisitorId();STORAGE_KEYS is a typed object of every key the SDK writes. Useful for migrations / debugging:
console.log(STORAGE_KEYS.authSession('pw_123')); // 'pw-pw_123-auth-v1'
console.log(STORAGE_KEYS.userState('pw_123', '<identity_hash>'));Next steps
- Security — why
localStorage(notHttpOnly cookies), threat model, XSS mitigations. - Session management —
AuthSession, refresh, cross-context. - BillingClient — what gets persisted in bootstrap.