Identity: two trust roots, one auth surface
Spotlight authenticates two very different populations:
- End users — the humans who land on your product and see tours, hotspots, checklists. They get identified by a JWT signed by the tenant's own identity provider (Auth0, Okta, Keycloak, custom JWKS). The SDK forwards it on every request.
- Platform users — the humans who log into the Spotlight dashboard to manage tours, invite teammates, switch projects. They sign in through a platform-wide identity provider (Clerk by default) and get a JWT signed by that.
Those are two different trust roots, validated against two different JWKS. Never mix them — an end-user token should never unlock dashboard admin actions, and a platform token should never be accepted as a stand-in for an end-user on a customer's page.
Why two roots and not one
The same person can be:
- A user of Spotlight as deployed on Company X's product (they get an Auth0 JWT from Company X's IdP).
- A platform admin of Company Y's Spotlight tenant (they sign into the dashboard with Clerk and manage Company Y's tours).
These are two hats on the same person. If we used one JWKS per tenant for both roles, a dashboard user would have to re-auth into every tenant's IdP just to switch which tenant they're managing. Worse: tenants' end-user identity stores (real customer databases, in some cases) would have to know about every Spotlight admin, and vice-versa. Keeping the roots separate is both simpler and safer.
What changes at the API
Two code paths into the same AuthContext:
┌──────────────────── X-API-Key + optional Authorization
│ ↓
Tenant edge ───── validate tenant JWT against tenant.jwks_url
↓
↓ (tenant + user + role via Admin.Users)
↓
┌──── AuthContext(auth_type="tenant", …)
│
│ ┌──────────────── Authorization only (no X-API-Key) +
│ │ X-Spotlight-Tenant-Id
│ │ ↓
Platform ───── validate platform JWT against Settings.platform_jwks_url
edge ↓
↓ (platform_user + tenant + role via
↓ Platform.Memberships)
↓
└──── AuthContext(auth_type="platform", …)Dispatch is purely header-shape — no API key means the platform path, the API key means the tenant path. Neither path leaks into the other: failure on the platform path (unknown tenant, no membership, invalid JWT) returns 401/403/404 without falling back to the tenant path.
The membership table
Spotlight.Platform.Memberships is a dedicated DynamoDB table linking (platform_user_id, tenant_id) → role:
| Attribute | Type | Notes |
|---|---|---|
platform_user_id | S (PK) | Clerk sub claim (user_XXXXX) |
tenant_id | S (SK) | Which tenant this link is for |
role | S | owner / admin / editor / viewer |
created_at | S | ISO-8601 |
GSI by-tenant (PK tenant_id, SK platform_user_id, projection ALL) answers the Members page query in one call.
Seeding SPOTLIGHT_PLATFORM_USER_ID in .env before make seed gets you an owner row immediately; without it the seed still creates a demo_platform_user placeholder so integration tests don't need extra fixtures.
Settings
Three platform-wide settings on the API (all blank by default — set once when you wire up Clerk or an equivalent):
| Env var | Purpose |
|---|---|
PLATFORM_JWKS_URL | JWKS endpoint for dashboard-token validation |
PLATFORM_JWT_ISSUER | Expected iss claim |
PLATFORM_JWT_AUDIENCE | Expected aud claim (leave blank if your Clerk template doesn't set one) |
When PLATFORM_JWKS_URL is unset, the platform dispatch silently short-circuits: any request without an API key gets rejected via the tenant path. That's how dev loops work without needing a fully-configured Clerk app.
When you'd add a third root
If you need a machine-to-machine identity (CI bots provisioning tenants, for example), that's a third adapter rather than a fork of either existing one. Drop it in backend/src/spotlight/infrastructure/auth/ and plumb the dispatch in get_auth_context. The shape of the landing AuthContext — tenant + caller + role — stays the same.