Skip to content

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:

  1. Dynamic Labs: Uses JWKS verification (existing implementation in utils/jwt.ts)
  2. Stack Auth: Uses server API call with secret key (current session.ts implementation)
  3. 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:

ProviderJWKS Endpoint
Dynamic Labshttps://app.dynamic.xyz/api/v0/sdk/{ENV_ID}/.well-known/jwks
Stack Authhttps://api.stack-auth.com/api/v1/projects/{PROJECT_ID}/.well-known/jwks.json
Privyhttps://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:

  1. Verifies JWTs using JWKS endpoints for all providers
  2. Caches JWKS keys for performance (10-minute TTL)
  3. Returns a normalized user object regardless of provider
  4. 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

ConceptDescription
Privy SessionStored on auth.privy.io domain, shared across all frontends in same browser
Frontend JWTsEach frontend gets its own JWT, stored in its own domain's cookies
Same User IDAll JWTs contain same sub claim (user's Privy DID)
Independent ExpiryEach JWT expires independently; Privy session may outlive individual JWTs
Logout ScopeLogging 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:

ClaimDynamicStack AuthPrivy
Subject (sub)User IDUser IDUser DID
Issuer (iss)--privy.io
Audience (aud)--App ID
Emailemail or verified_credentials[].emailemailemail
AlgorithmRS256RS256/ES256ES256

Implementation

typescript
// 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

env
# 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

  1. Phase 1: Create unified-jwt.ts utility with JWKS verification for all providers
  2. Phase 2: Update session.ts to use unified verifier instead of Stack API calls
  3. Phase 3: Add Privy as AUTH_PROVIDER option in monitor
  4. Phase 4: Update existing Dynamic verification to use unified utility
  5. Phase 5: Remove legacy utils/jwt.ts Dynamic-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 verifier
  • apps/monitor/src/lib/auth.ts - Add privy provider support
  • config/environments/services/monitor.interface.ts - Add PRIVY env vars

Released under the MIT License.