Security Model
The extension security model is built on four pillars: RS256 asymmetric JWT signing with a public JWKS, iframe sandboxing and origin pinning via the bridge protocol, short-lived tokens with auto-refresh, and strict scope ceilings that block human and admin access.
RS256 + JWKS
Extension-session JWTs are signed with an RSA private key (RS256) held server-side. Throttle publishes the corresponding public key(s) at GET /.well-known/extension-jwks.json in standard JWK Set format. Any out-of-process verifier — your extension backend, a middleware layer, a Cloudflare Worker — can verify tokens without a Throttle-specific SDK or a symmetric shared secret.
import { createRemoteJWKSet, jwtVerify } from 'jose';
// Create ONCE at module init — jose caches the JWKS and re-fetches on unknown kid.
const JWKS = createRemoteJWKSet(
new URL('https://api.usethrottle.dev/.well-known/extension-jwks.json'),
);
async function verifyToken(token: string) {
try {
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'throttle', // always 'throttle'
algorithms: ['RS256'], // only RS256 — no HS256
});
return payload;
} catch {
return null; // expired, bad sig, wrong issuer → reject
}
}- The protected header always carries
alg: "RS256"and akid. Reject tokens with any other algorithm. - The issuer is always
"throttle". Reject tokens with a different issuer even if the signature verifies. - The JWKS endpoint may publish multiple keys (key rotation). Your verification library selects the right key by
kidautomatically. Old keys remain in the set through their last token's TTL. - The endpoint is unauthenticated — no API key required. Cache it at module init via
createRemoteJWKSet.
kid, malformed structure — return null and reject the request. Never attempt to use a partially-validated payload.Iframe sandboxing and origin pinning
The dashboard renders extension iframes with a restrictive sandbox attribute. All communication between the iframe and the dashboard host goes through the bridge's typed postMessage protocol — the iframe cannot access the parent's DOM or navigate the parent directly (those requests go through the bridge's navigate helper, which the host controls).
<!-- The dashboard renders extension iframes with strict CSP sandboxing.
The sandbox attribute limits what the iframe can do.
Extension iframes cannot: access top-level window location,
open popups, navigate the parent, or capture device permissions
(camera, microphone) without explicit allow attributes. -->
<iframe
src="https://extensions.example.com/panel"
sandbox="allow-scripts allow-same-origin allow-forms"
allow=""
></iframe>
<!-- All cross-origin communication goes through the bridge postMessage protocol.
The bridge enforces origin pinning on both sides:
- iframe side: targetOrigin pins outgoing messages to the dashboard origin.
- host side: event.source === iframe.contentWindow validates the source window. -->Origin pinning works on both sides of the bridge:
- Iframe side — the
targetOriginoption passed tocreateBridge()restricts outgoingpostMessagecalls to the dashboard origin. Set this to'https://app.usethrottle.dev'in production. - Host side — the bridge host validates that inbound messages come from
iframe.contentWindowat the declaredtargetOrigin. Messages from any other window or origin are silently ignored.
targetOrigin, the bridge falls back to document.referrer and ultimately to '*'. A wildcard target sends the session token — including the signed JWT — to all windows in the frame stack. Always set targetOrigin to the exact dashboard origin.Short-lived tokens with auto-refresh
Extension-session JWTs have a 10-minute TTL. Short TTLs limit the exposure window if a token is intercepted. The bridge handles renewal automatically:
- When the current token is within 60 seconds of expiry, the bridge proactively sends a
refreshmessage to the host. - When a
bridge.api.*call receives a401, the bridge requests a refresh and retries the call once. - The host calls
POST /api/v1/installations/:id/launch-tokenand sends a freshsessionmessage with the new token. - All
bridge.api.*calls in flight during a refresh wait for the new token before proceeding.
Launch tokens can only be minted for active Clerk (dashboard) sessions — API keys cannot mint them. This ensures the token always embeds a real human identity and can be revoked by ending the Clerk session.
Scope ceilings
Extensions operate under a two-level scope ceiling. First, only extensionAllowed scopes may appear in a manifest — human scopes (team, billing, email, admin) are blocked at registration time. Second, the issued JWT and API key are scoped exactly to the pinned version's declared scopes — even if the workspace's API key holds broader access, the extension never inherits it.
// The extension token's scopes come from the pinned version's scopes list.
// Those scopes were validated against extensionAllowed at manifest registration.
// Human scopes (billing:*, team_members:*, admin:*) are never extensionAllowed.
// What this means at runtime:
// - Extensions can read orders, payments, customers, subscriptions, etc.
// - Extensions CANNOT read or write team members, billing plans, or admin data.
// - Extensions CANNOT request the wildcard scope '*'.
// - Extensions CANNOT manage other extensions (extensions:* scopes are extensionAllowed: false).
// Attempting to call a route beyond the token's scopes:
const res = await bridge.api.get('/api/v1/team-members'); // → 403 insufficient_scopesSee the Scopes reference for the full extensionAllowed catalog and the management scope details.
Webhook signature verification
Extension webhook deliveries are signed with HMAC-SHA256 using a per-install signing secret. The signature covers the delivery timestamp and the raw body, preventing replay attacks older than 5 minutes (configurable).
import { verifyWebhook } from '@usethrottle/extension-bridge/webhook';
function handleWebhook(rawBody: string, signatureHeader: string): boolean {
return verifyWebhook(
rawBody,
signatureHeader,
process.env.EXTENSION_WEBHOOK_SECRET!,
{ toleranceSeconds: 300 }, // default — reject deliveries > 5 min old
);
}
// Never use string equality — always use the constant-time comparison
// in verifyWebhook to prevent timing attacks on the HMAC.- The signing secret is unique per install — rotating it requires uninstalling and reinstalling.
- The
verifyWebhookhelper uses constant-time comparison to prevent timing attacks. - Never verify signatures with string equality (
===) — use the provided helper. - Capture the raw body before any JSON parsing. Parsing and re-serializing changes whitespace and key ordering and will break verification.
See the Events reference for the full verification workflow and event catalog.
Summary
- RS256 + public JWKS — no shared secret needed to verify tokens; key rotation is transparent to verifiers.
- Iframe sandboxing — restricts iframe capabilities; no direct DOM access to the parent.
- Origin pinning — postMessage is locked to the dashboard origin on both sides of the bridge.
- 10-minute token TTL — limits exposure window; auto-refresh keeps the session alive without user interaction.
- extensionAllowed scope ceiling — human, admin, and management scopes are never grantable to extensions.
- Per-install secrets — API key and webhook signing secret are unique per install; a compromised install is isolated.
- HMAC-SHA256 with replay protection — deliveries older than 5 minutes are rejected; constant-time comparison prevents timing attacks.