Skip to content

Clerk Integration

Overview

Clerk handles all authentication for RestartiX Platform. We use Clerk for signup, login, MFA, password reset, social auth, and session management. Our internal users table stores app-specific data and links to Clerk via clerk_user_id.

Why Clerk?

  • SOC 2 Type II compliant - Enterprise-grade security
  • HIPAA BAA available - Enterprise plan includes BAA signing
  • Pre-built UI components - Drop-in signup/login forms
  • Session management - Short-lived tokens, automatic rotation
  • Webhook-based sync - User changes propagate to our DB
  • Social auth - Google, Apple, etc. (if needed)

Architecture

┌─────────────────┐           ┌─────────────────┐           ┌─────────────────┐
│   Client App    │           │   Go API (Core API) │           │      Clerk      │
└────────┬────────┘           └────────┬────────┘           └────────┬────────┘
         │                              │                              │
         │──────  Login/Signup  ───────────────────────────────────────→│
         │                              │                              │
         │←───  Session Token  ─────────────────────────────────────────│
         │                              │                              │
         │                              │                              │
         │── API Request ──────────────→│                              │
         │   Authorization: Bearer      │                              │
         │   <clerk_session_token>      │                              │
         │                              │                              │
         │                              │──── Verify Token ───────────→│
         │                              │                              │
         │                              │←── Claims (clerk_user_id) ───│
         │                              │                              │
         │                              │  1. Lookup internal user     │
         │                              │  2. Load role, org, links    │
         │                              │  3. Set RLS session vars     │
         │                              │  4. Execute query            │
         │                              │                              │
         │←────── Response ─────────────│                              │
         │                              │                              │

User Synchronization (Webhooks)

Clerk sends webhooks to our API when user events occur. We process these to keep our users table in sync.

Webhook Events

EventActionNotes
user.createdCreate user in users tableUse email as username
user.updatedUpdate email/usernameEmail changes must propagate
user.deletedSet blocked = trueNever hard-delete (HIPAA requirement)

Webhook Endpoint

POST /webhooks/clerk

Headers:

  • Svix-Id - Unique message ID (for idempotency)
  • Svix-Timestamp - Webhook timestamp
  • Svix-Signature - HMAC signature (verified with CLERK_WEBHOOK_SECRET)

Authentication: Signature verification (not Bearer token)

Idempotency

Clerk may retry webhooks on network failures. We deduplicate using the Svix-Id header:

  1. Extract Svix-Id from headers
  2. Check Redis: SETNX clerk_webhook:<svix_id> 1 EX 259200 (72h TTL)
  3. If key already exists → skip processing, return 204
  4. If key didn't exist → process webhook, return 204
  5. On error → delete Redis key so retry can be processed

Why 72 hours? Clerk stops retrying after 3 days. TTL matches their retry window.

Implementation

go
// internal/domain/user/handler.go

func (h *Handler) ClerkWebhook(w http.ResponseWriter, r *http.Request) {
    // 1. Verify webhook signature (Clerk signs with CLERK_WEBHOOK_SECRET)
    payload, err := clerk.VerifyWebhook(r, h.webhookSecret)
    if err != nil {
        httputil.Unauthorized(w, "invalid webhook signature")
        return
    }

    // 2. Idempotency check
    eventID := r.Header.Get("Svix-Id")
    if eventID != "" {
        idempotencyKey := "clerk_webhook:" + eventID
        wasSet, err := h.redis.SetNX(r.Context(), idempotencyKey, "1", 72*time.Hour).Result()
        if err != nil {
            slog.Warn("redis idempotency check failed, processing anyway", "error", err)
            // Fail open — process rather than risk missing event
        } else if !wasSet {
            // Already processed
            slog.Info("duplicate webhook skipped", "event_id", eventID, "type", payload.Type)
            httputil.NoContent(w)
            return
        }
    }

    // 3. Process event
    switch payload.Type {
    case "user.created":
        var data clerk.UserCreatedEvent
        json.Unmarshal(payload.Data, &data)
        err = h.service.CreateFromClerk(r.Context(), &CreateFromClerkRequest{
            ClerkUserID: data.ID,
            Email:       data.EmailAddresses[0].EmailAddress,
            Username:    data.EmailAddresses[0].EmailAddress,
        })

    case "user.updated":
        var data clerk.UserUpdatedEvent
        json.Unmarshal(payload.Data, &data)
        err = h.service.UpdateFromClerk(r.Context(), data.ID, data.EmailAddresses[0].EmailAddress)

    case "user.deleted":
        var data clerk.UserDeletedEvent
        json.Unmarshal(payload.Data, &data)
        err = h.service.BlockUser(r.Context(), data.ID)
    }

    // 4. Error handling
    if err != nil {
        slog.Error("webhook processing failed", "type", payload.Type, "error", err)
        // Delete idempotency key so Clerk's retry will be processed
        if eventID != "" {
            h.redis.Del(r.Context(), "clerk_webhook:"+eventID)
        }
        httputil.InternalError(w)
        return
    }

    httputil.NoContent(w)
}

Clerk Configuration

Environment Variables

bash
# Core API (.env)
CLERK_SECRET_KEY=sk_live_...         # For token verification
CLERK_WEBHOOK_SECRET=whsec_...       # For webhook signature verification

Clerk Dashboard Setup

  1. Create Application - Sign up at https://clerk.com, create app

  2. Get Secret Key - Dashboard → API Keys → Secret Key

  3. Configure Webhook:

    • Dashboard → Webhooks → Add Endpoint
    • URL: https://api.restartix.com/webhooks/clerk
    • Events: user.created, user.updated, user.deleted
    • Copy webhook secret to CLERK_WEBHOOK_SECRET
  4. Frontend Setup:

    • Dashboard → API Keys → Publishable Key
    • Add to frontend env: NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_live_...

Session Token Lifetime

Clerk sessions are short-lived (default: 1 week) and automatically rotated. The frontend SDK handles token refresh transparently.

Token verification: Every API request verifies the token with Clerk via their SDK (cached for performance).

HIPAA Compliance

BAA Signing

Clerk provides a Business Associate Agreement (BAA) on the Enterprise plan. This is required for HIPAA compliance.

Action items:

  • [ ] Upgrade to Clerk Enterprise plan
  • [ ] Sign BAA via Clerk dashboard
  • [ ] Store signed BAA with compliance documentation

PHI in Clerk

Clerk stores:

  • Email address
  • Username (we use email)
  • Authentication metadata (IP, device, login history)

Not stored in Clerk:

  • Patient names, phone numbers, addresses
  • Medical data
  • Custom fields

All PHI lives in our database, not in Clerk.

Security Considerations

Token Verification

Every request to authenticated endpoints:

  1. Extracts Authorization: Bearer <token> header
  2. Verifies token with Clerk SDK
  3. Loads internal user from DB (single query)
  4. Blocks if users.blocked = true

Webhook Signature Verification

Webhooks are verified using HMAC signatures. Clerk signs every webhook with CLERK_WEBHOOK_SECRET. We verify before processing.

Security: Without signature verification, attackers could create fake webhooks to manipulate user data.

Failed Verifications

FailureResponseReason
Missing token401 UnauthorizedNo auth header
Invalid token401 UnauthorizedExpired or tampered
User blocked403 ForbiddenAccount disabled
User not found401 UnauthorizedNot synced from Clerk
Invalid webhook signature401 UnauthorizedForged webhook

Troubleshooting

User exists in Clerk but not in our DB

Cause: Webhook failed or wasn't received.

Fix:

  1. Check webhook delivery in Clerk dashboard (Webhooks → Logs)
  2. If failed, click "Retry"
  3. If missing, manually create user:
    sql
    INSERT INTO users (clerk_user_id, email, username, role)
    VALUES ('user_2abc...', '[email protected]', '[email protected]', 'patient');

User deleted in Clerk but still active in our DB

Cause: user.deleted webhook failed.

Fix:

  1. Check webhook logs
  2. Manually block user:
    sql
    UPDATE users SET blocked = true WHERE clerk_user_id = 'user_2abc...';

Duplicate webhook processing

Cause: Redis unavailable or Svix-Id header missing.

Impact: Low - operations are idempotent. CreateFromClerk uses INSERT ... ON CONFLICT DO NOTHING.

Fix: Ensure Redis is accessible. Check logs for redis idempotency check failed warnings.

Testing

Test Webhooks in Development

Clerk provides a webhook testing tool:

  1. Dashboard → Webhooks → Your Endpoint → Test
  2. Select event type (user.created, etc.)
  3. Click "Send Test"

The webhook will fire to your dev server (use ngrok if testing locally).

Manual User Creation (Testing Only)

For integration tests, bypass Clerk and create users directly:

go
user, err := userRepo.Create(ctx, &CreateUserRequest{
    Email:    "[email protected]",
    Username: "[email protected]",
    Role:     "patient",
})

Never do this in production. All production users must come from Clerk (HIPAA audit trail).

Migration from Legacy System

If migrating from an existing auth system (e.g., Strapi):

  1. Export users from legacy system (email, password hash)
  2. Import to Clerk via Clerk API or bulk import
  3. Sync clerk_user_id back to our DB
  4. Test login with migrated users

See ../../integrations/migration.md for full migration procedure.