Skip to content

Clerk Authentication Integration

See also: Clerk Webhook Implementation Plan for webhook handler setup that synchronizes user data between Clerk and the platform.

This document describes the pure event-sourced User authentication system integrated with Clerk for the LBS Foundry platform.

Overview

The authentication system combines: - Clerk: External authentication provider (handles signup, login, password management) - Event Sourcing: User aggregate with complete event-driven lifecycle - Projections: User data built from events as read models - Marten Storage: Events as source of truth, projections for queries - Custom ACL: Role-based authorization independent of Clerk

Pure Event Sourcing Architecture

Events are the source of truth:

Commands → Events → Projections → Authentication

  • User Commands: CreateUser, UpdateUser, DeleteUser
  • User Events: UserCreated, UserUpdated, UserDeleted
  • User Projection: UserContract built from events (read model)
  • Authentication: Queries projections, not events directly

Architecture

graph TD
    A[Frontend with Clerk] -->|JWT Token| B[API Authentication Handler]
    B --> C[Clerk JWT Validation]
    C --> D[User Provisioning Service]
    D --> E[User Projections Query]

    F[Clerk Webhooks] --> G[Webhook Importer]
    G --> H[User Commands]
    H --> I[User Aggregate]
    I --> J[User Events]
    J --> K[Event Store]

    K --> L[Projection Builder]
    L --> M[UserContract Projections]
    E --> M

    subgraph "Event Sourcing"
        H
        I
        J
        K
    end

    subgraph "Read Side"
        L
        M
        E
    end

Data Flow

1. User Authentication

1. User signs up/logs in via Clerk (frontend)
2. Clerk issues JWT token
3. Frontend sends requests with Bearer token
4. API validates JWT with Clerk
5. System provisions internal User if needed
6. Request proceeds with user context

2. User Lifecycle Management (Event Sourcing)

1. User changes occur in Clerk (signup, profile update, deletion)
2. Clerk sends webhook to API
3. Webhook importer processes event
4. Generates User commands (Create/Update/Delete)
5. User aggregate handles commands and generates events
6. Events stored in Marten event store
7. Projection builder updates UserContract projections
8. Authentication queries updated projections

Implementation Guide

Step 1: Configure Marten

Add to your Marten configuration:

services.AddMarten(options =>
{
    options.Connection(connectionString);

    // Add user authentication support
    options.ConfigureMartenForAuth();

    // Your existing configuration...
});

Step 2: Add Clerk Authentication

In Program.cs:

// Add Clerk authentication
builder.Services.AddClerkAuthentication(builder.Configuration["Clerk:SecretKey"]);

// Enable authentication middleware
app.UseAuthentication();
app.UseAuthorization();

Step 3: Configuration

Add to appsettings.json:

{
  "Clerk": {
    "SecretKey": "sk_test_your-clerk-secret-key"
  }
}

Step 4: Register Services

// In your DI registration

// Register webhook importer
services.AddImporter<ClerkWebhookImporter, ClerkWebhookImporterContract>("ClerkWebhook");

// Register user management service
services.AddScoped<IUserManagementService, UserManagementService>();

Step 5: Add Webhook Endpoint

Create an endpoint to receive Clerk webhooks:

[AllowAnonymous]
public class ClerkWebhookEndpoint : Endpoint<ClerkWebhookImporterContract, string>
{
    private readonly IImportExecutor _importExecutor;

    public ClerkWebhookEndpoint(IImportExecutor importExecutor)
    {
        _importExecutor = importExecutor;
    }

    public override void Configure()
    {
        Post("/api/webhooks/clerk");
        AllowAnonymous();
    }

    public override async Task HandleAsync(ClerkWebhookImporterContract req, CancellationToken ct)
    {
        var result = await _importExecutor.ExecuteAsync("ClerkWebhook", req);

        if (result.Success)
        {
            await SendOkAsync("Webhook processed", ct);
        }
        else
        {
            await SendAsync("Webhook processing failed", 500, ct);
        }
    }
}

Step 6: Configure Clerk Webhooks

In your Clerk dashboard:

  1. Go to Webhooks section
  2. Add endpoint: https://your-api.com/api/webhooks/clerk
  3. Select events: user.created, user.updated, user.deleted
  4. Add webhook secret to your configuration

Creating Users

Users are automatically created when they sign up through Clerk:

// No code needed - happens automatically via webhooks
// 1. User signs up in frontend via Clerk
// 2. Clerk sends user.created webhook  
// 3. System creates User via CreateUserCommand
// 4. UserCreatedEvent generated and stored
// 5. UserContract projection created

Method 2: Programmatically via Commands

public class UserService
{
    private readonly IUserManagementService _userService;

    public async Task<UserId> CreateUserAsync(string email, string firstName, string lastName)
    {
        // Create user via command (generates events)
        var userId = await _userService.CreateUserAsync(
            email: email,
            firstName: firstName,
            lastName: lastName
        );

        return userId;
    }
}

Method 3: Direct Command Execution

public class AdvancedUserService  
{
    private readonly ICommandExecutor _commandExecutor;

    public async Task CreateUserWithCustomPropertiesAsync()
    {
        var userId = UserId.NewLuckBoxUserId(Guid.NewGuid().ToString());

        var command = new CreateUserCommand
        {
            AggregateRootId = userId,
            Email = "user@example.com",
            FirstName = "John",
            LastName = "Doe",
            EmailVerified = true,
            Status = UserStatus.Active,
            Roles = ["Member", "Premium"]
        };

        // Execute command - generates UserCreatedEvent
        await _commandExecutor.ExecuteAsync(command);
    }
}

Clerk ID Mapping

When a user is created via Clerk webhook:

// Clerk webhook provides external ID
var clerkUserId = "user_2ABC123DEF456"; // From Clerk

// Internal User ID generated from Clerk ID
var userId = UserId.NewLuckBoxUserId(clerkUserId);

// Command links them
var command = new CreateUserCommand
{
    AggregateRootId = userId, // Internal ID
    ClerkUserId = clerkUserId, // External reference
    Email = "user@example.com"
    // ... other properties
};

Querying by Clerk ID

// Find user by Clerk ID
var user = await _userService.GetUserByClerkIdAsync("user_2ABC123DEF456");

// Or via session query
var user = await _session.Query<UserContract>()
    .Where(u => u.ClerkUserId == "user_2ABC123DEF456")
    .FirstOrDefaultAsync();

ID Relationship

Clerk User ID: "user_2ABC123DEF456" 
Internal User ID: Generated GUID based on Clerk ID
UserContract.ClerkUserId: "user_2ABC123DEF456" (reference back)

Usage Examples

Protecting Endpoints

FastEndpoints:

public class ProtectedEndpoint : Endpoint<MyRequest, MyResponse>
{
    public override void Configure()
    {
        Get("/api/protected");
        Policies(AuthorizationPolicies.RequireMember);
    }

    public override async Task HandleAsync(MyRequest req, CancellationToken ct)
    {
        // Access user information
        var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
        var clerkId = User.FindFirst("clerk_user_id")?.Value;
        var email = User.FindFirst(ClaimTypes.Email)?.Value;
        var isInRole = User.IsInRole("Member");

        // Your endpoint logic...
    }
}

Standard Controllers:

[Authorize(Policy = AuthorizationPolicies.RequireMember)]
[ApiController]
public class UserController : ControllerBase
{
    [HttpGet("profile")]
    public IActionResult GetProfile()
    {
        var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
        var email = User.FindFirst(ClaimTypes.Email)?.Value;

        return Ok(new { userId, email });
    }
}

Querying Users

// Direct Marten queries
public class UserService
{
    private readonly IDocumentSession _session;

    public async Task<List<MartenUser>> GetActiveUsersAsync()
    {
        return await _session.Query<MartenUser>()
            .Where(u => u.Status == UserStatus.Active)
            .ToListAsync();
    }

    public async Task<MartenUser?> GetByClerkIdAsync(string clerkId)
    {
        return await _session.Query<MartenUser>()
            .FirstOrDefaultAsync(u => u.ClerkUserId == clerkId);
    }
}

Managing User Commands

// Execute user commands directly
public class UserManagementService
{
    private readonly ICommandExecutor _commandExecutor;

    public async Task UpdateUserRoleAsync(UserId userId, List<string> roles)
    {
        var command = new UpdateUserCommand
        {
            AggregateRootId = userId,
            Roles = roles
        };

        await _commandExecutor.ExecuteAsync(command);
    }
}

Data Models

User Events (Source of Truth)

UserCreatedEvent:

{
  "aggregateRootId": "550e8400-e29b-41d4-a716-446655440000",
  "email": "user@example.com", 
  "firstName": "John",
  "lastName": "Doe",
  "clerkUserId": "user_2ABC123DEF456",
  "emailVerified": true,
  "createdAt": "2025-01-02T10:30:00Z",
  "status": "Active",
  "roles": ["Member"],
  "timestamp": "2025-01-02T10:30:00Z"
}

UserUpdatedEvent:

{
  "aggregateRootId": "550e8400-e29b-41d4-a716-446655440000",
  "email": "newemail@example.com",
  "roles": ["Member", "Premium"], 
  "lastSignInAt": "2025-01-02T15:45:00Z",
  "timestamp": "2025-01-02T15:45:00Z"
}

UserContract (Projection - Read Model)

Built from events via projection builder:

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "userName": "user@example.com",
  "normalizedUserName": "USER@EXAMPLE.COM", 
  "email": "user@example.com",
  "normalizedEmail": "USER@EXAMPLE.COM",
  "emailConfirmed": true,
  "firstName": "John",
  "lastName": "Doe",
  "fullName": "John Doe",
  "clerkUserId": "user_2ABC123DEF456",
  "createdAt": "2025-01-02T10:30:00Z",
  "lastLoginAt": "2025-01-02T15:45:00Z",
  "status": "Active",
  "roles": ["Member"],
  "version": 3,
  "lastUpdated": "2025-01-02T15:45:00Z",
  "securityStamp": "...",
  "concurrencyStamp": "..."
}

Event Store Structure

Stream: user-550e8400-e29b-41d4-a716-446655440000
├── UserCreatedEvent (version 1)
├── UserUpdatedEvent (version 2) 
└── UserUpdatedEvent (version 3)
   Projection Builder
   UserContract Document

Role Management

Default Behavior

  • New users automatically get "Member" role
  • Roles stored as simple string array
  • Custom authorization policies define permissions

Adding Custom Roles

// Update authorization policies
public static class AuthorizationPolicies
{
    public const string RequireMember = "RequireMember";
    public const string RequireAdmin = "RequireAdmin";

    public static void AddCustomPolicies(this AuthorizationOptions options)
    {
        options.AddPolicy(RequireMember, policy =>
            policy.RequireRole("Member"));

        options.AddPolicy(RequireAdmin, policy =>
            policy.RequireRole("Admin"));
    }
}

Event Sourcing Benefits

Full Audit Trail

  • Every user change is captured as an event
  • Complete history of user lifecycle
  • Immutable event log for compliance and debugging
  • Time travel: Reconstruct user state at any point in time
  • Event replay: Rebuild projections from events

Event Types

  • UserCreatedEvent: User registration/signup
  • UserUpdatedEvent: Profile changes, role assignments, login tracking
  • UserDeletedEvent: Account deletion/deactivation

Command → Event → Projection Flow

// 1. Execute command
await _commandExecutor.ExecuteAsync(new UpdateUserCommand 
{
    AggregateRootId = userId,
    Roles = ["Member", "Admin"]
});

// 2. Event automatically generated and stored
// UserUpdatedEvent created with roles change

// 3. Projection automatically updated  
// UserContract.Roles = ["Member", "Admin"]

// 4. Authentication uses updated projection
var user = await _session.LoadAsync<UserContract>(userId);
var isAdmin = user.Roles.Contains("Admin"); // true

Advanced Event Sourcing Operations

// Get complete user event history
var events = await _session.Events.FetchStreamAsync(userId.Value);

// Reconstruct user state at specific time
var userAggregate = await _session.Events.AggregateStreamAsync<UserAggregate>(
    userId.Value, timestamp: DateTime.UtcNow.AddDays(-30));

// Rebuild projection from events
await _session.Events.RebuildProjectionAsync<UserContract>(userId.Value);

// Get all events of specific type
var userCreatedEvents = await _session.Events
    .QueryAllRawEvents()
    .Where(e => e.EventTypeName == "UserCreated")
    .ToListAsync();

Projection Rebuilding

If you need to change the projection logic:

// Rebuild all user projections from events
await _session.Events.RebuildProjectionAsync<UserContractBuilder>();

// Or rebuild specific user
await _session.Events.RebuildProjectionAsync<UserContract>(userId.Value);

Security Considerations

JWT Token Validation

  • Tokens validated against Clerk's public keys
  • Automatic token expiration handling
  • Clerk user ID mapped to internal user ID

Authorization

  • Authentication handled by Clerk
  • Authorization managed internally via roles
  • Custom policies for fine-grained permissions

Webhook Security

  • Verify webhook signatures from Clerk
  • Use HTTPS for webhook endpoints
  • Implement idempotency for webhook processing

Troubleshooting

Common Issues

Authentication Fails: - Check Clerk secret key configuration - Verify token format (Bearer prefix) - Check Clerk dashboard for API key permissions

User Not Found: - User may not be provisioned yet - Check webhook delivery in Clerk dashboard - Verify webhook endpoint is accessible

Role Issues: - Check user role assignment in database - Verify authorization policy configuration - Ensure role names match exactly

Debugging

Enable detailed logging:

services.AddLogging(builder =>
{
    builder.AddFilter("LBS.Domain.Infrastructure.Authentication", LogLevel.Debug);
    builder.AddFilter("LBS.Domain.Infrastructure.Import", LogLevel.Debug);
});

Migration from Existing Auth

If migrating from Entity Framework Identity:

  1. Data Migration: Export existing users and roles
  2. Event Creation: Generate UserCreatedEvent for each user
  3. Role Mapping: Map EF roles to new role system
  4. Cleanup: Remove EF Identity dependencies
  5. Testing: Verify authentication flow end-to-end

This authentication system provides a robust, scalable foundation for user management with full event sourcing capabilities and seamless Clerk integration.