Authio docs

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_…"
}
Widget JWTs cannot be issued through the regular sign-in flow. The signer in 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/me on auth-core → 403 widget_token_not_allowed_here.
  • /v1/session/* on management-api → 403 widget_token_not_allowed_here.
  • /v1/widget-tokens itself 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.