Widgets
Widgets security model
Every guard the widget JWT and the auth-core /widget/* surface enforce, plus the threat model that motivates each.
Token shape
Widget JWTs are a third, fully separate JWT-kind alongside the existing customer-tenant and platform-tenant kinds. The wire shape:
{
"iss": "https://auth-api.authio.com",
"kind": "widget",
"tenant_id": "ten_acme",
"organization_id": "org_acme_hq",
"widget_scope": ["sso_connection"],
"widget_origins": ["https://app.acme.com"],
"exp": <iat + 1800s>,
"iat": <now>,
"nbf": <now>,
"aud": ["authio"],
"jti": "wtok_…",
"sub": "wtok_…"
}authio_auth-core rejects any SignAccessToken call that sets Kind=widget with ErrWidgetKindNotAllowedHere. The only path to a widget JWT is POST /v1/widget-tokens on management-api → /internal/widget-tokens/sign on auth-core.TTL — default 30 minutes, hard cap 1 hour
The mint endpoint clamps ttl_seconds to [60, 3600] with a 1800-second default. The DB check (widget_tokens.expires_at <= minted_at + 1 hour) refuses any longer-lived row at INSERT time, so a bug in the API layer can’t silently widen the cap. The rationale:
- Widgets are interactive — an IT admin clicks a configure-SSO button, completes the wizard, walks away. 30 minutes covers even slow customers; longer windows just expand the stolen-token impact radius.
- Customers who need a longer-lived embed should mint a fresh token from their BFF on every page load (the BFF holds a long-lived dashboard session JWT — that’s the appropriate place to keep state). The widget tokens are short-lived precisely so the bearer can’t outlive the embed surface that mints it.
Origin enforcement (Q3 lock)
The biggest sleight of hand a widget bearer enables is cross-app embedding: a customer mints a token for app.acme.com, an attacker grabs it, then runs the widget inside evil.example.com. To prevent that, every /widget/* request runs an exact match between the request’s Origin header (or Referer if absent) and the JWT’s widget_origins[]. Mismatch → 403 widget_origin_mismatch.
We deliberately did not rely on a client-side frame-ancestors CSP — those can be defeated by a misconfigured iframe sandbox, and the customer’s BFF already controls where the widget JWT is shipped. Founder decision in the WorkOS competitive analysis Q3 lock: “avoid client-side auth headaches.”
Scope model
widget_scope[] is a closed set today — sso_connection and directory_sync. Each route asserts the required scope is in the JWT’s scope list before doing any work; mismatch → 403 widget_scope_required. New widgets get new scope names, never a wildcard.
Revocation — DB-row-backed, not just JWT exp
The middleware loads the underlying widget_tokens row by JTI on every call and refuses if revoked_at IS NOT NULL — even if the JWT’s exp claim hasn’t fired yet. The management-API’s DELETE /v1/widget-tokens/:id sets revoked_at = now(); the next widget call sees the revocation within milliseconds.
No escalation — widget tokens cannot mint other tokens
Widget JWTs are explicitly refused by every other surface:
/v1/sessions/*on auth-core →403 widget_token_not_allowed_here./v1/meon auth-core →403 widget_token_not_allowed_here./v1/session/*on management-api →403 widget_token_not_allowed_here./v1/widget-tokensitself on management-api → same (you mint widget tokens from a dashboard session, not from another widget token)./v1/admin/*on management-api → the api-key gate rejects any non-sk_(live|test)_…bearer; widget JWTs don’t match the regex.
Audit log
Every widget action emits an audit event with actor_type="widget", actor_id=<widget_tokens.minted_by_user_id>, and metadata.via="widget". The mint and revoke calls on POST/DELETE /v1/widget-tokens emit their own events with the operator user as the actor and metadata.via="widget_token". Audit consumers (the dashboard, audit streams, S3 Object Lock fan-out) ingest both flavours unchanged.
RLS
The widget_tokens table is RLS-gated under the same authio.tenant_id / authio.project_id GUC pattern every other tenant-scoped table uses (migration 0033_row_level_security.sql). Platform admins read across all tenants via the __all__ sentinel; customer-tenant code paths bind to one tenant per transaction and can’t enumerate other tenants’ rows. FORCE ROW LEVEL SECURITY mirrors the recent compliance tables so a future code path with table-owner privileges can’t bypass the policy.
