Security
SDK 3.0 is designed for public clients — browsers, Chrome extensions, Telegram Mini Apps. This page covers defensive assumptions, known trade-offs, and best practices for integrators.
Threat model
What we defend against:
- Charging other people’s accounts / draining their quotas (defended via Bearer resolution on the backend).
- Forged checkout URLs (server-driven, the SDK doesn’t sign anything).
- Cross-popup OAuth attacks (PKCE state nonce + popup-name matching).
What we don’t defend against:
- XSS on the host page — if an attacker runs JS in your origin, the tokens are theirs (see below).
- Reverse engineering the bundle — it’s public code; any “API key” hardcoded inside is useless (the server-side
apiKeyoption throwsapikey_in_browserif it ever reaches a browser, so it can’t end up in a bundle by accident). - Attacks on the platform’s auth backend — out of scope for the SDK.
Token storage and XSS
By default AuthClient stores access_token and refresh_token in localStorage (web) or chrome.storage.local (extension). This is a deliberate trade-off:
| Approach | For | Against |
|---|---|---|
| localStorage (default) | Survives reloads, works in Chrome extensions, no cookie issues with SameSite/iframe | Available to any JS on the page → XSS leaks it |
| HttpOnly cookies | JS can’t see the token; XSS won’t leak it immediately | Doesn’t work in Chrome extensions; cross-origin headaches; SDK 3.0 is intentionally cookie-free |
| sessionStorage | Doesn’t survive reload (smaller attack window) | User logs in again on every new tab — UX hurt |
| In-memory only | Minimum attack surface | Reload = logout, useless for most SaaS |
Mitigations built into the SDK:
- Refresh-token rotation on the backend — every successful refresh invalidates the previous refresh token. A leaked refresh token is likely already invalid by the time the attacker uses it (if the victim refreshed at least once after the leak).
- Short access-token lifetime — 1 hour. A leaked access token can’t be extended without the refresh token.
destroy()on teardown — on hot reload / SPA route changes, storage subscriptions are removed so no dangling references hold stale tokens.
What the host should do:
- CSP —
Content-Security-Policy: script-src 'self' <whitelist>blocks unknown JS injection (the main XSS vector). - Subresource integrity for CDN scripts — if you use
<script src="https://cdn.example.com/...">, addintegrity="sha256-..."so a CDN compromise can’t inject an XSS payload. - Sanitize user-generated content — never render
innerHTMLwithoutDOMPurify, never usedangerouslySetInnerHTMLwithout validation. - Secure cookies for your own backend — Bearer in SDK 3.0 is for OUR API. For YOUR API calls use your own auth (cookies/session).
import { AuthClient, type StorageAdapter } from '@monetize.software/sdk/core';
// Replace storage with in-memory — for high-security environments (enterprise).
// The user will log in on every page load — this is a compromise.
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); }
};
})();
const auth = new AuthClient({
paywallId: 'pw_123',
storage: memoryStorage
});Bearer vs API key vs X-User-ID
| Method | Where to use | SDK check |
|---|---|---|
Bearer (AuthClient) | Browser, extension — the main path. Backend resolves user.id securely from the token; can’t be spoofed. | None — this is the safe default. |
apiKey | Server-side only (Node, Deno, Bun, Edge). Server-SDK key from the platform. | Throws apikey_in_browser in browsers (window.document detection) — a naive client integration fails to construct instead of leaking the key. allowInsecureBrowserUsage: true downgrades it to console.error for e2e only. |
X-User-ID (ApiGatewayClient.userId) | Headless server scenario with a trusted user.id from your backend. | console.warn in browsers without auth — a client could spoof somebody else’s ID; the backend checks Bearer more strictly. |
// ❌ In the browser — the SDK throws on construction
const billing = new BillingClient({
paywallId: 'pw_123',
apiKey: 'sk_live_...' // throws PaywallError('apikey_in_browser')
});
// ✅ In Node — fine
// server.ts (Express handler)
const billing = new BillingClient({
paywallId: 'pw_123',
apiKey: process.env.MONETIZE_API_KEY
});For end-to-end server-side patterns (per-user Bearer, storage adapters, webhook handlers, token credit/debit) — see Headless / server-side.
Custom fetch
BillingClient, AuthClient, ApiGatewayClient accept fetch?: typeof fetch in their options. Convenient for tests / undici / proxies, but unsafe with untrusted code:
A custom fetch sees Authorization: Bearer ... on every request. Never pass a fetch from untrusted packages (npm deps with auto-updates, CDN scripts). If you do use a custom fetch — don’t log init.headers in production.
OAuth popup and postMessage
signInWithOAuth opens a popup → callback → postMessage. The cross-popup attack defence is a PKCE state nonce generated locally and passed via popup.name = pw-oauth-<state>. postMessage origin is not validated (the callback page sends targetOrigin: '*' because of COOP restrictions), but the state nonce is unique per popup and unguessable.
What this protects:
- A background popup on another page sending you a postMessage with a forged code — it fails the state check.
What this does NOT protect:
- XSS on your page — if attacker has JS access they can also listen for postMessage. Covered by general anti-XSS (CSP + sanitize).
Cross-context auth in extensions
See auth/session → Cross-context sync. In short: in a Chrome extension the session is shared across all contexts via chrome.storage.local. If the content script is untrusted (the user is on a hostile page), Bearer should only live in the background — the content script talks to background via chrome.runtime.sendMessage and only the background does fetches with Authorization.
// content-script.ts (UNTRUSTED — can run on any page)
chrome.runtime.sendMessage({ type: 'gateway.call', providerId, body }, (res) => {
/* ... */
});
// background.ts (trusted)
import { AuthClient, BillingClient } from '@monetize.software/sdk/core';
const auth = new AuthClient({ paywallId: 'pw_123' });
const billing = new BillingClient({ paywallId: 'pw_123', auth });
const gateway = billing.createApiGatewayClient();
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg.type !== 'gateway.call') return;
gateway.call(msg).then((r) => r.json()).then(sendResponse);
return true;
});Idempotency
To prevent double-clicks / retries over unreliable networks from double-charging or duplicating records:
createCheckout({idempotencyKey})— required in production. The SDK deduplicates parallel clicks bypriceIdautomatically, but second-or-two retries — no.signUp({idempotencyKey})/signInWithEmail({idempotencyKey})— protection against double-submit in forms. Treat it as a hint to our façade validation rather than a strict guarantee on the auth backend.gateway.call({...})— no idempotency yet. A double-click on a CTA in an AI app = two requests = two credits. If this matters, dedupe on the UI side (disable the button until the response).
Production checklist
- CSP configured (
script-srcwhitelist). -
apiKeyNOT in the client bundle (CI grep check). -
paywall.destroy()/auth.destroy()called on teardown. -
idempotencyKeypassed intocreateCheckout(UUID v4). - If threat model is high — considered an in-memory storage adapter.
- HTTPS everywhere (no HTTP in production).