| name | jwt-embed-issuance |
| description | Canonical 2026 JWT-embed flow for dashboard embedding — required claims (`sub`, `tenant_id`, `iat`, `exp`, `iss`, `aud`, `nonce`), tool-specific verification (Superset guest tokens, Metabase JWT URLs, Cube Authorization Bearer, Power BI MSAL-via-AAD), 5-15 min expiration policy, cross-boundary denial test contract. Invoked by `ravenclaude-core/security-reviewer` for any embed-auth review. |
Skill: jwt-embed-issuance
Invoked by: ravenclaude-core/security-reviewer (mandatory for any embed-auth change). Generated by dashboard-builder when scaffolding a new embed.
When to invoke: any change touching JWT issuance, embed authentication, tenant-claim injection, key rotation. Mandatory pre-merge review.
Output: verified JWT-issuance code + tenant-claim shape + expiration policy + rotation plan + cross-boundary denial test pass.
The canonical 2026 flow
[End user] -- logs in --> [Host application (client's web app)]
|
| Authenticated session
v
[App's JWT issuer endpoint]
|
| Issues short-lived JWT (5-15 min)
| Claims: user_id, tenant_id, allowed_dashboards[], optionally explicit RLS rules
| Signature: HS256 (shared secret) or RS256 (asymmetric, preferred for cross-system trust)
v
[Front-end React app]
|
| Passes JWT to embed (iframe param, SDK call, Cube REST/SQL header)
v
[Embed verifies signature, extracts claims, enforces RLS at query time]
|
| Query runs scoped to tenant_id
v
[Data returned, scoped to tenant_id]
Required claims
Every embed JWT must carry:
| Claim | Purpose | Format |
|---|
sub | Subject (user identifier) | string (user ID from host app) |
tenant_id | The tenant boundary the viewer can see | string or integer |
iat | Issued-at timestamp | unix seconds |
exp | Expiration timestamp | unix seconds (5-15 min from iat) |
iss | Issuer (host app identifier) | string URL or app name |
aud | Audience (the embed tool) | string ("metabase" / "superset" / "cube") |
Optional but recommended:
allowed_dashboards — array of dashboard IDs the viewer can access (defense in depth)
roles — for Power BI Embedded, the DAX role(s) to apply
nonce — replay protection
Tool-specific verification patterns
Apache Superset
- Issuer side: HS256 with
GUEST_TOKEN_JWT_SECRET matching Superset's config
- Embed side: front-end SDK calls Superset's
/api/v1/security/guest_token; Superset validates + applies the rls claim filters server-side
- See Superset embedding docs
Metabase Interactive Embedding (Pro+ only)
- Issuer side: JWT-signed with
embedding-secret-key from Metabase admin
- Embed side: Metabase SDK or iframe URL with JWT parameter
- Tenant scoping via
user.email claim + Metabase Sandbox (Pro+ feature)
Cube
- Issuer side: HS256 with
CUBEJS_API_SECRET
- Embed side: every Cube query carries the JWT in
Authorization: Bearer <jwt> header
- Tenant scoping via
securityContext rules in the Cube schema that reference securityContext.tenant_id
Power BI Embedded (App-Owns-Data)
- Not strictly JWT-flow; uses MSAL acquisition of a Power BI access token via service principal
- Tenant scoping via DAX roles (
EffectiveIdentity parameter)
- The "JWT" here is the Azure AD token, not an app-issued one
Expiration policy
- Default: 5-15 minute expiration. Stateless revocation otherwise requires a revocation list (operational overhead).
- Refresh pattern: front-end requests a new token from the host app's JWT-issuer endpoint when the current one is near expiry (typically at ~80% of lifetime).
- Long-lived JWTs (>30 min) are an anti-pattern. The hook (
../../hooks/flag-data-platform-smells.sh) flags these in JWT-issuance code.
Key rotation
- Asymmetric (RS256 + JWKS): rotate the signing key without coordinating with the embed-verifier; verifier fetches public keys from a JWKS endpoint
- Symmetric (HS256 + shared secret): rotation requires coordination — both sides must update at the same time. For SMB consulting engagements, this is usually acceptable; for productized SaaS scaling to many clients, prefer RS256.
Cross-boundary denial test
Every JWT-issuance code path ships with a denial test that:
- Creates two test tenants with disjoint data
- Issues a JWT for tenant A
- Attempts to query tenant B's data via the embed
- Test passes only when zero rows return
The test ships in the engagement's CI; failure means RLS or securityContext is misconfigured.
Required code-template companions
The skill ships with ../../templates/jwt-issuer.ts — a Node/TS scaffold that demonstrates:
- Short-lived token issuance with
tenant_id claim
- Signing-key management via env var (never inline)
- Rotation hook (signing key version in token header)
- Audience claim per embed tool
- Smoke test that the verification round-trip works
Anti-patterns this skill flags
- JWT with
exp > 30 minutes
- Signing key hard-coded in source (use env var; the hook catches this)
- Tenant ID derived from the URL or query parameter (must come from the JWT claim, signed by the host app)
- JWT issued without
iss and aud claims (replay across systems possible)
- Front-end React code that constructs the JWT client-side (must come from server-issued endpoint)
- Long-lived "API tokens" reused across viewers (no per-viewer scope)
- HS256 + shared secret for a productized SaaS with many tenants (rotate-without-coordination is too valuable; switch to RS256 + JWKS)
- A JWT-issuer endpoint without rate-limiting on the host app (token-issuance becomes a DoS vector)
- Cross-boundary denial test not run / not present
- Power BI Embedded mistakenly trying to use app-issued JWTs (Power BI uses Azure AD tokens via MSAL)
References