ADR: JWKS-Based Unified Token Verification
Status: Proposed Date: 2025-12-30 Deciders: Engineering Team Extends: 2025-12-21-unified-auth-provider-abstraction
Context and Problem Statement
Building on the unified auth provider abstraction, we need a consistent method to verify JWT tokens from all supported providers. Currently:
- Dynamic Labs: Uses JWKS verification (existing implementation in
utils/jwt.ts) - Stack Auth: Uses server API call with secret key (current
session.tsimplementation) - Privy: Not yet implemented
Each provider has a different verification approach, leading to inconsistent code and maintenance overhead.
Discovery
All three providers support JWKS (JSON Web Key Set) endpoints for JWT verification:
| Provider | JWKS Endpoint |
|---|---|
| Dynamic Labs | https://app.dynamic.xyz/api/v0/sdk/{ENV_ID}/.well-known/jwks |
| Stack Auth | https://api.stack-auth.com/api/v1/projects/{PROJECT_ID}/.well-known/jwks.json |
| Privy | https://auth.privy.io/api/v1/apps/{APP_ID}/jwks.json |
This enables a unified verification approach using standard JWKS-based JWT validation.
Decision
Implement a unified JWKS-based token verification utility in emprops-api that:
- Verifies JWTs using JWKS endpoints for all providers
- Caches JWKS keys for performance (10-minute TTL)
- Returns a normalized user object regardless of provider
- Eliminates the need for server-side API calls with secret keys
Multi-App Architecture with Privy
The following diagram shows how multiple applications can share a single Privy auth configuration while maintaining their own user contexts:
Token Verification Flow
Single Sign-On (SSO) Across Multiple Frontends
When using a single Privy App ID across multiple frontends, users get automatic SSO. Privy maintains its own session on auth.privy.io, separate from the JWTs issued to each frontend.
SSO Login Flow: First Frontend vs Subsequent Frontends
Key Concepts
| Concept | Description |
|---|---|
| Privy Session | Stored on auth.privy.io domain, shared across all frontends in same browser |
| Frontend JWTs | Each frontend gets its own JWT, stored in its own domain's cookies |
| Same User ID | All JWTs contain same sub claim (user's Privy DID) |
| Independent Expiry | Each JWT expires independently; Privy session may outlive individual JWTs |
| Logout Scope | Logging out of one frontend doesn't log out of others (but can log out of Privy entirely) |
Provider-Specific JWT Claims
Each provider includes different claims in their JWTs:
| Claim | Dynamic | Stack Auth | Privy |
|---|---|---|---|
Subject (sub) | User ID | User ID | User DID |
Issuer (iss) | - | - | privy.io |
Audience (aud) | - | - | App ID |
email or verified_credentials[].email | email | email | |
| Algorithm | RS256 | RS256/ES256 | ES256 |
Implementation
// apps/emprops-api/src/utils/unified-jwt.ts
export type AuthProvider = 'dynamic' | 'stack' | 'privy';
export interface JwtVerificationResult {
valid: boolean;
userId: string | null;
email: string | null;
claims: Record<string, unknown>;
provider: AuthProvider;
error?: string;
}
// JWKS endpoints by provider
const JWKS_ENDPOINTS = {
dynamic: (envId: string) =>
`https://app.dynamic.xyz/api/v0/sdk/${envId}/.well-known/jwks`,
stack: (projectId: string) =>
`https://api.stack-auth.com/api/v1/projects/${projectId}/.well-known/jwks.json`,
privy: (appId: string) =>
`https://auth.privy.io/api/v1/apps/${appId}/jwks.json`,
};
export async function verifyToken(
token: string,
provider: AuthProvider
): Promise<JwtVerificationResult>;Environment Configuration
# Dynamic Labs
DYNAMIC_ENVIRONMENT_ID=abc123
# Stack Auth
STACK_PROJECT_ID=2dd79195-ac90-44a7-af26-cf83abb53623
# Privy
PRIVY_APP_ID=cmjjh7wv800ill70cl60nywpa
# Which provider each app uses
AUTH_PROVIDER=privy # or 'stack' or 'dynamic'Consequences
Positive
- Unified Verification: One code path for all providers using JWKS
- No Server Secrets Required: JWKS uses public keys, no need to store provider secret keys for verification
- Industry Standard: JWKS is RFC 7517, well-supported by libraries
- Cached Performance: Keys are cached, avoiding per-request network calls
- Multi-App Ready: Single Privy app can serve multiple frontend applications
Negative
- JWKS Dependency: Requires providers to maintain their JWKS endpoints
- Key Rotation: Must handle key rotation gracefully (cache invalidation)
- Network Dependency: Initial key fetch requires network call
Mitigations
- Caching: 10-minute cache TTL balances freshness with performance
- Fallback Keys: Cache multiple keys to handle rotation periods
- Error Handling: Graceful degradation if JWKS endpoint is temporarily unavailable
Migration Path
- Phase 1: Create
unified-jwt.tsutility with JWKS verification for all providers - Phase 2: Update
session.tsto use unified verifier instead of Stack API calls - Phase 3: Add Privy as AUTH_PROVIDER option in monitor
- Phase 4: Update existing Dynamic verification to use unified utility
- Phase 5: Remove legacy
utils/jwt.tsDynamic-only implementation
Files Changed
New Files
apps/emprops-api/src/utils/unified-jwt.ts- Unified JWKS verification
Modified Files
apps/emprops-api/src/routes/auth/session.ts- Use unified verifierapps/monitor/src/lib/auth.ts- Add privy provider supportconfig/environments/services/monitor.interface.ts- Add PRIVY env vars
