Skip to Content
SDK v3newStorage adapters

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:

RuntimeImplementationCross-context sync
Chrome Extension (has chrome.runtime.id)chrome.storage.localchrome.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 / testsIn-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';
KeyContentsIdentity-bound
pw-visitor-idUUID v4 — stable visitor identifier for analytics. Not PII.
pw-{paywallId}-auth-v1AuthSession (access_token, refresh_token, expires_at, user). Source of truth for login.
pw-{paywallId}-anon-rt-v1Refresh 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-v1Persisted bootstrap (settings/prices/offers/layout/locales/version). 1h TTL.
pw-{paywallId}-{identityKey}-user-v1Last-known PaywallUser — offline fallback. Key is tied to identity so another user’s state doesn’t leak after a switch.
pw-{paywallId}-{identityKey}-balances-v1Persisted 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-emailUser’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:

CaseWhat to supply
High-security / enterprise — tokens must not survive a reloadIn-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 cb for your own setItem (otherwise you get a loop).
  • value: string | nullnull means either “deleted” or “never existed”.
  • unsubscribe is 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 lastLoginEmail over pw-{paywallId}-last-login-email. The SDK will overwrite it.
  • Don’t double-prefix keys in your adapter. The SDK already prefixes with pw- and paywallId. Double prefixing is a migration bug.
  • Don’t add watch to an in-memory adapter. Cross-context sync requires multiple processes; for a single process watch is 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 (not HttpOnly cookies), threat model, XSS mitigations.
  • Session managementAuthSession, refresh, cross-context.
  • BillingClient — what gets persisted in bootstrap.