Skip to main content

Clerk Integration

The Auth service receives webhook events from Clerk, syncs user data to PostgreSQL, and provides JWT validation middleware used by the gateway.

Webhook Flow

Clerk Dashboard

▼ (webhook POST)
Gateway (:8080)
└── /api/v1/auth/webhook (public, no JWT)


Auth Service (:8200)
├── Verify Clerk signature
├── Parse event payload
├── Upsert user in gl_users
└── Publish to gl:events:users Redis Stream

Webhook Events

EventAction
user.createdInsert new user into gl_users, publish user.created event
user.updatedUpdate user record (email, name), publish user.updated event
session.createdUpdate last_seen_at on user device record

Webhook Handler

// services/auth/internal/clerk/webhook.go
func (h *WebhookHandler) HandleUserCreated(w http.ResponseWriter, r *http.Request) {
var event UserCreatedEvent
if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
render.Status(r, 400)
render.JSON(w, r, ErrBadRequest)
return
}

// Validate Clerk webhook signature
if !clerk.VerifySignature(r, h.cfg.ClerkWebhookSecret) {
render.Status(r, 401)
render.JSON(w, r, ErrUnauthorized)
return
}

// Persist to gl_users
user := &domain.User{
ClerkID: event.Data.ID,
Email: event.Data.EmailAddress[0].EmailAddress,
CreatedAt: time.Unix(event.Data.CreatedAt/1000, 0),
}
if err := h.userRepo.Upsert(r.Context(), user); err != nil {
render.Status(r, 500)
render.JSON(w, r, ErrInternal)
return
}

// Publish to Redis Stream for downstream consumers
h.events.Publish(r.Context(), "gl:events:users", event)
render.Status(r, 200)
render.JSON(w, r, map[string]string{"status": "ok"})
}

Signature Verification

Every incoming webhook is verified against Clerk's signing secret (GOSPELIB_AUTH_CLERK_WEBHOOK_SECRET). The signature is checked before any event processing occurs.

JWT Validation Flow

Client Request

├── Authorization: Bearer <JWT>


Gateway Middleware
├── 1. Extract token from Authorization header
├── 2. Fetch Clerk JWKS (cached, refreshed periodically)
├── 3. Verify RS256 signature
├── 4. Check token expiry (exp claim)
├── 5. Validate issuer (iss claim matches Clerk instance)
├── 6. Extract user claims (sub, plan)
└── 7. Inject X-User-Id and X-User-Plan headers


Downstream Service
└── Reads X-User-Id, X-User-Plan from headers (trusts gateway)

Token Claims

ClaimHeaderDescription
subX-User-IdClerk user ID (e.g., user_abc123)
custom planX-User-PlanSubscription plan (e.g., free, scholar, academic)

JWKS Caching

The JWKS (JSON Web Key Set) from Clerk is cached in memory with periodic refresh. This avoids a network call on every request while ensuring key rotation is handled.

Idempotency

Webhook events from Clerk may be delivered more than once. The auth service handles this via upsert semantics:

  • Upsert(user) — Inserts if clerk_id doesn't exist, updates if it does
  • The user's updated_at timestamp is always refreshed on upsert
  • Redis Stream consumers downstream handle duplicate events idempotently

User Endpoints

GET /users/{id}

Returns structured user profile data for internal service-to-service calls. Not exposed directly to external clients — accessed via the gateway.

GET /users/{id}/entitlements

Joins user record with their billing plan to return a list of entitled features. Used by the gateway's entitlement middleware to populate the Redis cache.