Nov 19, 2024

Clerk + Encore

Production-ready auth with SSO, organizations, and instant deployment

14 Min Read

Modern applications need robust user authentication, but building it from scratch means dealing with session management, password security, OAuth providers, and countless edge cases. Clerk provides a complete user management platform that handles all of this through pre-built UI components and a powerful API.

In this tutorial, we'll build a backend that uses Clerk for user authentication and management. You'll learn how to verify sessions, protect API endpoints, and access user data with full type safety.

What is Clerk?

Clerk is a complete user management platform designed for modern applications. It provides:

  • Pre-built UI Components - Sign-in, sign-up, and user profile interfaces
  • Multiple Auth Methods - Email/password, social logins (Google, GitHub, etc.), magic links, and passkeys
  • Session Management - Secure, JWT-based sessions with automatic refresh
  • User Management - Complete user profiles, metadata, and roles
  • Organizations - Multi-tenant support with team management
  • Webhooks - Real-time events for user lifecycle changes

Clerk handles the entire authentication flow, from UI to session management, letting you focus on building your product.

What we're building

We'll create a backend with complete user authentication:

  • Session verification for API requests
  • Protected endpoints that require authentication
  • User profile access with type-safe data
  • Auto-provisioned PostgreSQL database for storing user preferences
  • Organization support for multi-tenant apps

The backend will verify Clerk sessions, provide authenticated user context to your endpoints, and store user data in a PostgreSQL database that's automatically provisioned by Encore.

Clerk Sign-in UI

Getting started

Prefer to skip the setup? Use encore app create --example=ts/clerk-simple to start with a complete working example. This tutorial walks through building it from scratch to understand each component.

First, install Encore if you haven't already:

# macOS brew install encoredev/tap/encore # Linux curl -L https://encore.dev/install.sh | bash # Windows iwr https://encore.dev/install.ps1 | iex

Create a new Encore application. This will prompt you to create a free Encore account if you don't have one (required for secret management):

encore app create clerk-app --example=ts/hello-world cd clerk-app

Setting up Clerk

Creating your Clerk account

  1. Go to clerk.com and sign up for a free account
  2. Create a new application in the Clerk Dashboard
  3. Navigate to API Keys and copy your secret key
  4. Note your Frontend API URL (looks like https://your-app.clerk.accounts.dev)

Tip: Clerk offers a generous free tier with 10,000 monthly active users included.

Installing the Clerk SDK

Install the Clerk backend SDK:

npm install @clerk/backend

Backend implementation

Creating the auth service

Every Encore service starts with a service definition file (encore.service.ts). Services let you divide your application into logical components. At deploy time, you can decide whether to colocate them in a single process or deploy them as separate microservices, without changing a single line of code:

// auth/encore.service.ts import { Service } from "encore.dev/service"; export default new Service("auth");

Configuring Clerk

To use Clerk's API, you need to authenticate with a secret key. Encore provides built-in secrets management to securely store sensitive values like API keys:

// auth/clerk.ts import { createClerkClient } from "@clerk/backend"; import { secret } from "encore.dev/config"; const clerkSecretKey = secret("ClerkSecretKey"); export const clerk = createClerkClient({ secretKey: clerkSecretKey(), });

The secret() function creates a reference to a secret value that's stored securely outside your code. Set your Clerk secret key for your local development environment:

# Development encore secret set --dev ClerkSecretKey # Production encore secret set --prod ClerkSecretKey

Tip: Find your secret key in the Clerk Dashboard under API Keys.

Creating an auth handler

To protect endpoints and enable authentication across your application, create an auth handler. The auth handler verifies Clerk session tokens and makes authenticated user data available to protected endpoints:

// auth/handler.ts import { APIError, Gateway, Header } from "encore.dev/api"; import { authHandler } from "encore.dev/auth"; import { verifyToken } from "@clerk/backend"; import { secret } from "encore.dev/config"; import { clerk } from "./clerk"; const clerkSecretKey = secret("ClerkSecretKey"); interface AuthParams { authorization: Header<"Authorization">; } export interface AuthData { userID: string; email: string; firstName?: string; lastName?: string; } export const auth = authHandler<AuthParams, AuthData>(async (params) => { const token = params.authorization?.replace("Bearer ", ""); if (!token) { throw APIError.unauthenticated("missing session token"); } try { // Verify the JWT token with Clerk const payload = await verifyToken(token, { secretKey: clerkSecretKey(), }); // Get user details from the JWT payload const userId = payload.sub; if (!userId) { throw new Error("No user ID in token"); } const user = await clerk.users.getUser(userId); return { userID: user.id, email: user.emailAddresses.find((e) => e.id === user.primaryEmailAddressId) ?.emailAddress || "", firstName: user.firstName || undefined, lastName: user.lastName || undefined, }; } catch (err) { throw APIError.unauthenticated("invalid session token"); } }); export const gateway = new Gateway({ authHandler: auth, });

This auth handler:

  1. Extracts the session token from the Authorization header
  2. Verifies it with Clerk's API
  3. Fetches the user's details
  4. Returns authenticated user data

Setting up the database

While Clerk handles authentication, you'll often want to store additional user data and preferences in your own database. With Encore, you can create a PostgreSQL database by simply defining it in code. The framework automatically provisions the infrastructure locally using Docker.

Create the database instance:

// user/db.ts import { SQLDatabase } from "encore.dev/storage/sqldb"; export const db = new SQLDatabase("user", { migrations: "./migrations", });

Create the migration file to define your user profile table:

-- user/migrations/1_create_user_profile.up.sql CREATE TABLE user_profile ( clerk_user_id TEXT PRIMARY KEY, theme TEXT DEFAULT 'light', notifications BOOLEAN DEFAULT true, bio TEXT, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP );

That's it! Encore automatically handles applying migrations when your application starts.

Protected endpoints

Now you can create protected endpoints that require authentication. Create a user service:

// user/encore.service.ts import { Service } from "encore.dev/service"; export default new Service("user");

Add an endpoint to get the current user's profile, combining Clerk data with your database:

// user/profile.ts import { api } from "encore.dev/api"; import { getAuthData } from "~encore/auth"; import { db } from "./db"; interface ProfileResponse { userId: string; email: string; firstName?: string; lastName?: string; theme?: string; notifications?: boolean; bio?: string; } export const getProfile = api( { expose: true, method: "GET", path: "/user/profile", auth: true }, async (): Promise<ProfileResponse> => { const auth = getAuthData()!; // Get user preferences from database let profile = await db.queryRow<{ theme: string; notifications: boolean; bio: string | null; }>` SELECT theme, notifications, bio FROM user_profile WHERE clerk_user_id = ${auth.userID} `; // Create profile with defaults if it doesn't exist if (!profile) { await db.exec` INSERT INTO user_profile (clerk_user_id, theme, notifications) VALUES (${auth.userID}, 'light', true) `; profile = { theme: "light", notifications: true, bio: null }; } return { userId: auth.userID, email: auth.email, firstName: auth.firstName, lastName: auth.lastName, theme: profile.theme, notifications: profile.notifications, bio: profile.bio || undefined, }; } );

The auth: true flag tells Encore this endpoint requires authentication. The getAuthData() function provides type-safe access to the authenticated user's information.

Updating user preferences

Now create an endpoint to update user preferences in your database:

// user/profile.ts (continued) interface UpdatePreferencesRequest { theme?: "light" | "dark"; notifications?: boolean; bio?: string; } interface UpdatePreferencesResponse { success: boolean; } export const updatePreferences = api( { expose: true, method: "POST", path: "/user/preferences", auth: true }, async (req: UpdatePreferencesRequest): Promise<UpdatePreferencesResponse> => { const auth = getAuthData()!; // Upsert user preferences (insert or update if exists) await db.exec` INSERT INTO user_profile (clerk_user_id, theme, notifications, bio, updated_at) VALUES (${auth.userID}, ${req.theme || "light"}, ${req.notifications ?? true}, ${req.bio || null}, NOW()) ON CONFLICT (clerk_user_id) DO UPDATE SET theme = COALESCE(${req.theme}, user_profile.theme), notifications = COALESCE(${req.notifications}, user_profile.notifications), bio = COALESCE(${req.bio}, user_profile.bio), updated_at = NOW() `; return { success: true }; } );

This endpoint stores user preferences in your PostgreSQL database, giving you full control over the data and the ability to query it efficiently.

Handling user webhooks (optional)

While user profiles are automatically created when they first access the API, you can optionally use Clerk webhooks for more immediate synchronization. Webhooks are useful if you need to perform actions the moment a user signs up or is deleted, rather than on their first API request.

Create a webhook handler:

// auth/webhooks.ts import { api } from "encore.dev/api"; import { db } from "../user/db"; import log from "encore.dev/log"; interface ClerkWebhookEvent { type: string; data: { id: string; email_addresses?: Array<{ email_address: string }>; first_name?: string; last_name?: string; }; } export const handleWebhook = api.raw( { expose: true, path: "/webhooks/clerk", method: "POST" }, async (req, res) => { const body = await req.json(); const event = body as ClerkWebhookEvent; switch (event.type) { case "user.created": log.info("User created", { userId: event.data.id, email: event.data.email_addresses?.[0]?.email_address, }); // Create user profile with defaults await db.exec` INSERT INTO user_profile (clerk_user_id, theme, notifications) VALUES (${event.data.id}, 'light', true) `; break; case "user.updated": log.info("User updated", { userId: event.data.id }); // User profile will be updated through the API break; case "user.deleted": log.info("User deleted", { userId: event.data.id }); // Clean up user data await db.exec` DELETE FROM user_profile WHERE clerk_user_id = ${event.data.id} `; break; } res.writeHead(200); res.end(); } );

This webhook handler creates database profiles immediately when users sign up and cleans up data when they're deleted.

To enable webhooks:

  1. Deploy your application to get a public URL
  2. In the Clerk Dashboard, go to Webhooks
  3. Add your webhook endpoint: https://your-domain.com/webhooks/clerk
  4. Select the events you want to receive (user.created, user.updated, user.deleted)

Important: In production, verify webhook signatures to ensure requests are from Clerk. Add the svix package and use Clerk's signing secret to verify each webhook request.

Testing locally

Start your backend (make sure Docker is running first):

encore run

Your API is now running locally. Open the local development dashboard at http://localhost:9400 to explore your API with interactive documentation.

Note: When testing locally with Clerk's development keys, you'll see a warning in the browser console about development instances having strict usage limits. This is normal for local development. For production deployments, you'll need to configure production instances in the Clerk Dashboard.

Getting a session token

To test your API, you'll need a valid Clerk session token. The easiest way is to:

  1. Set up a frontend with Clerk's React SDK
  2. Sign in through Clerk's UI components
  3. Get the session token using await getToken() from useAuth()
  4. Use that token in your API requests

Alternatively, for quick testing, you can create a test user in the Clerk Dashboard and use the Clerk Backend API to generate a session.

Test protected endpoint

Once you have a session token:

curl http://localhost:4000/user/profile \ -H "Authorization: Bearer <your-clerk-session-token>"

Response:

{ "userId": "user_2abc123", "email": "[email protected]", "firstName": "John", "lastName": "Doe" }

Update user preferences

curl -X POST http://localhost:4000/user/preferences \ -H "Authorization: Bearer <your-clerk-session-token>" \ -H "Content-Type: application/json" \ -d '{"theme":"dark","notifications":true}'

The local dashboard's distributed tracing shows you the complete flow of each request, including the Clerk token verification and database operations:

Distributed Tracing

Exploring the local dashboard

The local development dashboard at http://localhost:9400 provides:

  • API Explorer - Test all your endpoints interactively
  • Service Catalog - Auto-generated API documentation
  • Architecture Diagram - Visual representation of your services
  • Distributed Tracing - See the full flow of each request including Clerk API calls
  • Database Explorer - Browse your user profiles, run queries, and debug data issues

Local Development Dashboard

The database explorer is particularly useful for verifying that user profiles are being created correctly and preferences are being stored as expected.

Database Explorer

Deployment

Self-hosting

See the self-hosting instructions for how to use encore build docker to create a Docker image and configure it.

Encore Cloud Platform

Deploy your application to a staging environment using git push encore:

git add -A . git commit -m "Add Clerk authentication" git push encore

Set your production secret:

encore secret set --prod ClerkSecretKey

Note: Encore Cloud is great for prototyping and development with fair use limits (100k requests/day, 1GB database). For production workloads, you can connect your AWS or GCP account and Encore will provision and deploy infrastructure directly in your cloud account.

Frontend integration

Static file frontend for testing

For quick prototyping and testing, you can serve a static HTML frontend directly from your Encore app. This is perfect for demos and can be deployed immediately alongside your backend.

Create a frontend service:

// frontend/encore.service.ts import { Service } from "encore.dev/service"; export default new Service("frontend");
// frontend/frontend.ts import { api } from "encore.dev/api"; export const assets = api.static({ expose: true, path: "/!path", dir: "./assets", });

Then create frontend/assets/index.html with Clerk's browser SDK (replace YOUR_PUBLISHABLE_KEY with your actual Clerk publishable key from the Clerk Dashboard under API Keys):

<!DOCTYPE html> <html> <head> <title>Clerk Auth Demo</title> <script src="https://cdn.jsdelivr.net/npm/@clerk/clerk-js@5/dist/clerk.browser.js" data-clerk-publishable-key="YOUR_PUBLISHABLE_KEY"> </script> <style> body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #eeeee1; padding: 40px; margin: 0; } .container { max-width: 600px; margin: 0 auto; } h1 { text-align: center; color: #1a1a1a; } .user-greeting { text-align: center; margin-bottom: 0; } .user-greeting h2 { font-size: 32px; margin-bottom: 16px; } .user-greeting-row { display: flex; align-items: center; justify-content: center; gap: 12px; color: #666; } .profile-section { background: white; border-radius: 12px; padding: 32px; margin-top: 24px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); } </style> </head> <body> <div class="container"> <h1>Clerk Authentication Demo</h1> <div id="loading">Loading...</div> <div id="clerk-sign-in"></div> <div id="user-greeting" class="user-greeting" style="display: none;"> <h2 id="greeting-title"></h2> <div class="user-greeting-row"> <p>Click here to manage your account or log out →</p> <div id="clerk-user-button"></div> </div> </div> <div id="profile" class="profile-section" style="display: none;"></div> </div> <script> const API_URL = window.location.hostname === 'localhost' ? 'http://localhost:4000' : window.location.origin; let clerk; async function initClerk() { clerk = window.Clerk; await clerk.load(); document.getElementById('loading').style.display = 'none'; if (clerk.user) { await showProfile(); } else { clerk.mountSignIn(document.getElementById('clerk-sign-in')); } clerk.addListener(({ user }) => { if (user) { showProfile(); } }); } async function showProfile() { // Show greeting const user = clerk.user; const firstName = user.firstName || user.emailAddresses[0]?.emailAddress?.split('@')[0] || 'there'; document.getElementById('greeting-title').textContent = `Hello, ${firstName}! 👋`; document.getElementById('user-greeting').style.display = 'block'; // Mount user button clerk.mountUserButton(document.getElementById('clerk-user-button')); // Get JWT token and fetch profile const token = await clerk.session.getToken(); const response = await fetch(`${API_URL}/user/profile`, { headers: { 'Authorization': `Bearer ${token}` } }); const profile = await response.json(); document.getElementById('profile').innerHTML = ` <h2>Profile</h2> <p><strong>Email:</strong> ${profile.email}</p> <p><strong>Name:</strong> ${profile.firstName} ${profile.lastName}</p> <p><strong>Theme:</strong> ${profile.theme}</p> <p><strong>Notifications:</strong> ${profile.notifications ? 'Enabled' : 'Disabled'}</p> `; document.getElementById('profile').style.display = 'block'; } window.addEventListener('load', () => { setTimeout(initClerk, 100); }); </script> </body> </html>

Note: This static frontend approach is great for quick prototypes and testing. It can be deployed immediately with your backend using git push encore, making it perfect for sharing demos. For production applications with more complex requirements, consider using a dedicated frontend framework with services like Vercel or Netlify.

React/Next.js integration

For production applications, use Clerk's pre-built UI components with your favorite framework:

import { ClerkProvider, SignIn, UserButton } from "@clerk/clerk-react"; function App() { return ( <ClerkProvider publishableKey="your-publishable-key"> <SignIn /> <UserButton /> </ClerkProvider> ); }

Call your Encore backend with the session token:

import { useAuth } from "@clerk/clerk-react"; function Dashboard() { const { getToken } = useAuth(); const fetchProfile = async () => { const token = await getToken(); const response = await fetch("https://your-api-url.com/user/profile", { headers: { Authorization: `Bearer ${token}` } }); return response.json(); }; }

After integrating your frontend, redeploy your backend with git push encore to update both together. For complete frontend integration guides, see the frontend integration documentation.

Advanced features

Organizations (Multi-tenancy)

Clerk supports organizations out of the box. To add organization support to your auth handler:

// auth/handler.ts (updated) export interface AuthData { userID: string; email: string; firstName?: string; lastName?: string; organizationID?: string; role?: string; } export const auth = authHandler<AuthParams, AuthData>(async (params) => { const token = params.authorization?.replace("Bearer ", ""); if (!token) { throw APIError.unauthenticated("missing session token"); } try { const payload = await verifyToken(token, { secretKey: clerkSecretKey(), }); const userId = payload.sub; if (!userId) { throw new Error("No user ID in token"); } // Extract organization info from token const orgId = payload.org_id as string | undefined; const orgRole = payload.org_role as string | undefined; const user = await clerk.users.getUser(userId); return { userID: user.id, email: user.emailAddresses.find((e) => e.id === user.primaryEmailAddressId) ?.emailAddress || "", firstName: user.firstName || undefined, lastName: user.lastName || undefined, organizationID: orgId || undefined, role: orgRole || undefined, }; } catch (err) { throw APIError.unauthenticated("invalid session token"); } });

Now you can access organization context in your endpoints:

export const getTeamData = api( { expose: true, method: "GET", path: "/team", auth: true }, async (): Promise<TeamResponse> => { const auth = getAuthData()!; if (!auth.organizationID) { throw APIError.permissionDenied("not in an organization"); } // Fetch team-specific data return { organizationId: auth.organizationID, role: auth.role, }; } );

Custom session claims

Add custom data to session tokens:

await clerk.users.updateUser(userId, { publicMetadata: { plan: "premium", credits: 100, }, });

Access in your auth handler:

const user = await clerk.users.getUser(userId); const plan = user.publicMetadata.plan;

Rate limiting by user

Use Encore's caching to implement per-user rate limiting:

import { CacheKeyspace } from "encore.dev/storage/cache"; const rateLimits = new CacheKeyspace<{ count: number }>("rate_limits", { defaultExpiry: 60, // 1 minute }); export const limitedEndpoint = api( { expose: true, method: "GET", path: "/limited", auth: true }, async () => { const auth = getAuthData()!; const key = `user:${auth.userID}`; const current = await rateLimits.get(key); const count = (current?.count || 0) + 1; if (count > 10) { throw APIError.resourceExhausted("rate limit exceeded"); } await rateLimits.set(key, { count }); // Your endpoint logic } );

Multi-factor authentication

Clerk supports MFA out of the box. Enable it in your Clerk Dashboard under User & AuthenticationMulti-factor.

Users can enable MFA in Clerk's pre-built user profile component, and Clerk handles the verification flow automatically.

Next steps

If you found this tutorial helpful, consider starring Encore on GitHub to help others discover it.

Encore

This blog is presented by Encore, the backend framework for building robust type-safe distributed systems with declarative infrastructure.

Like this article?
Get future ones straight to your mailbox.

You can unsubscribe at any time.