Extension Identity JWT
When the dashboard opens an extension's iframe, it mints a short-lived RS256-signed JWT that identifies the installation, the acting user, and the current workspace and application context. The iframe receives this token through the bridge handshake and uses it as a bearer on every API call.
Full claim set
The JWT carries both standard claims (iss, sub, aud, iat, exp) and the full ExtensionSessionClaims object as top-level custom claims.
{
// Standard JWT claims
"iss": "throttle",
"sub": "<installationId>",
"aud": "<extensionId>",
"iat": 1748000000,
"exp": 1748000600, // 10-minute TTL
// Extension identity claims (ExtensionSessionClaims)
"installationId": "inst_01HZX...",
"extensionId": "ext_01HZX...",
"version": "1.0.0", // pinned semver of the installed version
"workspace": { "id": "ws-uuid", "slug": "acme" },
"application": { "id": "app-uuid", "slug": "acme-store" },
"environment": {
"environmentId": "env-uuid",
"environmentSlug": "uat",
"environmentKind": "non_production",
"providerEnvironment": "sandbox"
},
"user": { "id": "user-clerk-id", "email": "alice@acme.com", "name": "alice@acme.com" },
"role": "admin", // app-level role: admin | developer | finance | viewer
"scopes": ["orders:read", "customers:read"] // from the pinned version
}issis always"throttle". Reject tokens with any other issuer.subis theinstallationId.audis theextensionId.environmentreflects the install's workspace environment and matches the API key environment.roleis the acting user's application-level role (admin | developer | finance | viewer).scopesare the scopes from the pinned version — a ceiling on what the token may do.user.nameis currently set to the same value asuser.email(workspace members do not have a separate display name column today).
How the dashboard delivers the token
The dashboard creates a BridgeHost (from @usethrottle/extension-bridge) that wraps the iframe. When the iframe loads and calls createBridge(), the bridge immediately posts a ready message to the parent. The dashboard calls POST /api/v1/installations/:id/launch-token and replies with a session message carrying token, expiresAt, and a context object. The iframe bridge resolves bridge.ready with that context.
// The dashboard calls this endpoint automatically during the bridge handshake.
// You do not need to call it manually from inside the iframe — the bridge
// handles the mint and refresh cycle for you.
// If you need a token outside the bridge (e.g. for server-to-server calls
// from your extension backend), mint one explicitly:
const res = await fetch(
'https://api.usethrottle.dev/api/v1/installations/inst_xxx/launch-token',
{
method: 'POST',
headers: {
Authorization: 'Bearer <clerk-dashboard-session>',
'X-Throttle-Application-Id': '<applicationId>',
'X-Throttle-Environment-Id': '<environmentId>',
'content-type': 'application/json',
},
body: JSON.stringify({}),
},
);
const { data } = await res.json();
// data.token — the RS256 JWT
// data.expiresAt — ISO 8601 expiry (10 minutes from now)POST /api/v1/installations/:id/launch-token is Clerk-JWT only — it requires an authenticated dashboard session. API keys cannot mint launch tokens because tokens embed a human user identity. Attempts with an API key return 400 launch_token_requires_dashboard_session.TTL and auto-refresh
Tokens have a 10-minute TTL. The bridge tracks the expiry time and proactively sends a refresh message to the host when the token is within 60 seconds of expiry, or immediately on receiving a 401 from the API. The host mints a fresh token and sends a new session message. All pending and in-flight bridge.api.* calls wait for the refresh before proceeding.
// bridge.api.* handles refresh automatically.
// If you hold the raw token, refresh when it is within 60s of expiry.
// The bridge exposes getToken() to read the current token:
const currentToken = bridge.getToken();
// The bridge posts a 'refresh' message to the host when the token is
// within 60 seconds of expiry, or immediately on receiving a 401.
// The host calls POST /api/v1/installations/:id/launch-token and replies
// with a 'session' message carrying the fresh token.Verify tokens in your backend
If your extension calls your own backend, pass the bridge token in the Authorization: Bearer header and verify it against the public JWKS. The JWKS endpoint is unauthenticated — no key needed.
GET https://api.usethrottle.dev/.well-known/extension-jwks.jsonimport { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(
new URL('https://api.usethrottle.dev/.well-known/extension-jwks.json'),
);
export async function verifyExtensionToken(token: string) {
try {
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'throttle',
algorithms: ['RS256'],
});
// payload carries all ExtensionSessionClaims:
return {
installationId: payload.installationId as string,
extensionId: payload.extensionId as string,
version: payload.version as string,
workspace: payload.workspace as { id: string; slug: string },
application: payload.application as { id: string; slug: string },
environment: payload.environment as {
environmentId: string;
environmentSlug: string;
environmentKind: 'production' | 'non_production';
providerEnvironment: 'production' | 'sandbox';
},
user: payload.user as { id: string; email: string; name: string },
role: payload.role as string,
scopes: payload.scopes as string[],
};
} catch {
return null; // expired, bad signature, wrong issuer — always fail closed
}
}null / reject on any verification failure — expired tokens, unknown key ID, wrong issuer, bad signature. Never attempt to use a partially-validated payload. The Throttle SDK's verifyExtensionSessionToken follows this contract: it returns null on any error and never throws.Using the token as an API bearer
The extension-session JWT is also accepted by the Throttle API as a Bearer token on /api/v1/* routes. The token's scopes claim is used as the caller's granted scope set, exactly as with an API key. This means the iframe can call the API directly without the extension's dedicated API key being present in the browser.
// The iframe bridge automatically attaches the token as a Bearer on every
// bridge.api.* call. If you make direct fetch() calls from the iframe, do the same:
const token = bridge.getToken();
const res = await fetch('https://api.usethrottle.dev/api/v1/orders?limit=10', {
headers: {
Authorization: `Bearer ${token}`,
'content-type': 'application/json',
},
});scopes are the pinned version's scopes, which are themselves a subset of the extensionAllowed scopes. Calling a route that requires a scope not in the token's list returns 403 insufficient_scopes.Key rotation
The JWKS endpoint may publish multiple keys (identified by their kid header). Your verification library (e.g. jose's createRemoteJWKSet) automatically selects the right key by kid and caches the JWKS response. Key rotation adds new key(s) to the set before retiring old ones — in-flight tokens remain valid through their TTL, and the JWKS cache handles the transition.
createRemoteJWKSet instance once at module initialization and reuse it. The jose library caches the JWKS and re-fetches on unknown kid. Recreating it per-request makes an unnecessary HTTP call on every token verification.See also: Security model for the full threat model around JWT issuance and iframe isolation.