docs/authentication.mdx
WorldMonitor uses Clerk for authentication. The auth system gates premium panels behind sign-in and tier checks, and enforces session-based access on server-side API endpoints via local JWT verification.
| Layer | Technology | Purpose |
|---|---|---|
| Auth provider | Clerk | Sign-in (email, social), session management, hosted UI |
| JWT verification | jose + Clerk JWKS | Server-side bearer token validation (no round-trip) |
| Convex integration | Clerk JWT template (convex) | Convex auth with applicationID: "convex" |
| Auth state | auth-state.ts | Reactive browser auth state, role caching |
| File | Purpose |
|---|---|
convex/auth.config.ts | Convex auth provider config — Clerk JWT issuer + applicationID |
src/services/clerk.ts | Clerk instance init, getClerkToken() for Convex JWT template |
src/services/auth-state.ts | Reactive auth state, role fetching, session hydration |
src/components/AuthHeaderWidget.ts | Header sign-in button, Clerk UserButton |
server/auth-session.ts | Server-side JWT validation with jose + cached JWKS |
Premium panels show a CTA overlay instead of content until the user meets the access requirements.
| Reason | What the user sees | Resolution |
|---|---|---|
ANONYMOUS | "Sign In to Unlock" | Sign in via Clerk |
FREE_TIER | "Upgrade to Pro" | Upgrade subscription |
NONE | Normal panel content | Already unlocked |
Three files control gating. All three must stay in sync when adding or removing premium panels.
src/config/panels.tsAdd premium: 'locked' to the panel entry in the relevant variant:
// In FULL_PANELS, FINANCE_PANELS, etc.
'my-panel': { name: 'My Panel', enabled: true, premium: 'locked' }
src/app/panel-layout.tsAdd the panel key to WEB_PREMIUM_PANELS:
const WEB_PREMIUM_PANELS = new Set([
'stock-analysis',
'stock-backtest',
'daily-market-brief',
'my-panel', // <-- add here
]);
This set drives the reactive UI gating — when auth state changes, panels in this set get checked and show/hide CTAs accordingly.
Client token injection — src/services/runtime.ts (WEB_PREMIUM_API_PATHS):
const WEB_PREMIUM_API_PATHS = new Set([
'/api/market/v1/analyze-stock',
'/api/market/v1/get-stock-analysis-history',
'/api/market/v1/backtest-stock',
'/api/market/v1/list-stored-stock-backtests',
'/api/my-domain/v1/my-endpoint', // <-- add here
]);
When a fetch request matches a path in this set and the user has a Clerk session, the client automatically injects Authorization: Bearer <token>.
Server gateway — server/gateway.ts (PREMIUM_RPC_PATHS):
const PREMIUM_RPC_PATHS = new Set([
'/api/market/v1/analyze-stock',
'/api/market/v1/get-stock-analysis-history',
'/api/market/v1/backtest-stock',
'/api/market/v1/list-stored-stock-backtests',
'/api/my-domain/v1/my-endpoint', // <-- add here
]);
The gateway validates the bearer token via local JWKS verification (jose) and checks session.role === 'pro'. Returns 403 if the user isn't pro.
| Panel | Variants | Gate type |
|---|---|---|
stock-analysis | full, finance | locked (web) |
stock-backtest | full, finance | locked (web) |
daily-market-brief | full, finance | locked (web) |
Desktop users with a valid WORLDMONITOR_API_KEY in the Tauri keychain bypass all panel gating. The existing API key flow is unaffected — bearer tokens are a second auth path, not a replacement.
The Vercel API gateway accepts two forms of authentication for premium endpoints:
X-WorldMonitor-Key header (existing flow, unchanged)Authorization: Bearer <clerk_jwt> (for web users)The gateway tries the API key first. If that fails on a premium endpoint, it falls back to bearer token validation using local JWKS verification via server/auth-session.ts. The JWT is verified against:
CLERK_JWT_ISSUER_DOMAINconvex (matches the Clerk JWT template)Non-premium endpoints don't require any authentication from web origins.
| Variable | Where | Purpose |
|---|---|---|
CLERK_JWT_ISSUER_DOMAIN | Convex + Vercel | Clerk issuer domain for JWT verification |
VITE_CLERK_PUBLISHABLE_KEY | Vercel | Client-side Clerk publishable key |
User roles (pro / free) are stored as a plan claim in the Clerk JWT. The server extracts this from the verified token payload. Unknown or missing plan values default to free (fail closed — never pro).
On the client side, getAuthState().user?.role exposes the role. Both isProUser() and hasPremiumAccess() check this alongside legacy API key gates.