ClerkPhoenix
A focused authentication library for Phoenix applications that integrates with Clerk without making assumptions about user management or business logic.
Introduction
I am quite new to Elixir and made this library for my own use using Claude Code. This library is currently a personal project and is not officially supported or endorsed by Clerk. Use at your own discretion.
⚠️ Disclaimer
This software is provided "as is" without warranty of any kind. I cannot be held responsible for any damages, data loss, security issues, or other problems that may arise from using this library. Use this software at your own risk and always thoroughly test in your own environment before deploying to production.
Design Philosophy
ClerkPhoenix is designed around a core principle: authentication libraries should handle authentication, not user management.
- ✅ Authentication: Token validation, identity extraction, session management
- ❌ User Management: User models, user business logic, user-specific features
This separation creates cleaner boundaries, better flexibility, and allows applications to define their own user models and management patterns.
Prerequisites
- Phoenix 1.7+ application
- Clerk account and application configured (Get started here)
- Basic understanding of Phoenix plugs and pipelines
Installation
Add clerk_phoenix to your list of dependencies in mix.exs:
def deps do
[
{:clerk_phoenix, "~> 0.2.0"}
]
end
Then run mix deps.get.
JavaScript Hooks (for LiveView integration)
ClerkPhoenix ships LiveView JS hooks. To use them, ensure your esbuild config includes deps in NODE_PATH:
# config/config.exs
config :esbuild,
version: "0.17.11",
your_app: [
args: ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets),
cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
]
Then import and register the hooks in your assets/js/app.js:
import { hooks as clerkHooks } from "clerk_phoenix";
const liveSocket = new LiveSocket("/live", Socket, {
hooks: { ...clerkHooks, /* your other hooks */ }
});Quick Start
1. Get Your Clerk Keys
- Go to your Clerk Dashboard
- Select your application
- Go to "API Keys" section
- Copy your keys
2. Environment Variables
Create a .env file in your project root:
CLERK_PUBLISHABLE_KEY=pk_test_your_publishable_key_here
CLERK_SECRET_KEY=sk_test_your_secret_key_here
CLERK_FRONTEND_API_URL=https://your-clerk-frontend-api.clerk.accounts.devImportant: Add .env to your .gitignore file to keep secrets secure.
3. Configuration
Configure ClerkPhoenix in your config/runtime.exs:
import Config
import Dotenvy
require Logger
# Load .env file if it exists
if File.exists?(".env") do
Logger.info("found .env file")
source!([".env", System.get_env()])
else
Logger.warning("cannot find .env file to load")
end
# Configure ClerkPhoenix
config :your_app, ClerkPhoenix,
publishable_key: env!("CLERK_PUBLISHABLE_KEY"),
secret_key: env!("CLERK_SECRET_KEY"),
frontend_api_url: env!("CLERK_FRONTEND_API_URL"),
# Optional frontend route configurations
sign_in_url: "/sign-in",
sign_up_url: "/sign-up",
after_sign_in_url: "/dashboard",
after_sign_up_url: "/profile"Additional dependencies: You'll also need {:dotenvy, "~> 1.0.0"} for .env file support. Run mix deps.get to install dependencies.
4. Router Setup
Update your lib/your_app_web/router.ex:
defmodule YourAppWeb.Router do
use YourAppWeb, :router
# Base browser pipeline
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {YourAppWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug ClerkPhoenix.Plug.FrontendConfigPlug, otp_app: :your_app # Make Clerk config available in templates
end
# Optional authentication pipeline
pipeline :auth do
plug ClerkPhoenix.Plug.AuthPlug, otp_app: :your_app
end
# Required authentication pipeline
pipeline :require_auth do
plug ClerkPhoenix.Plug.AuthPlug, mode: :require_auth, otp_app: :your_app
# Add your user fetching plug here
# plug YourApp.UserPlug
end
# Public routes
scope "/", YourAppWeb do
pipe_through :browser
get "/", PageController, :home
get "/sign-in", PageController, :sign_in
get "/sign-up", PageController, :sign_up
end
# Optional auth routes (user might or might not be signed in)
scope "/", YourAppWeb do
pipe_through [:browser, :auth]
get "/dashboard", PageController, :dashboard
end
# Protected routes (authentication required)
scope "/", YourAppWeb do
pipe_through [:browser, :require_auth]
get "/profile", PageController, :profile
get "/settings", PageController, :settings
end
endAvailable Connection Assigns
ClerkPhoenix automatically sets these assigns on the connection:
# In your controllers and templates
@authenticated? # boolean - whether user is authenticated
@identity # map - extracted identity claims from JWT
@auth_context # map - authentication metadata
@token_claims # map - raw JWT claims (for debugging)
@clerk_config # map - frontend configuration for Clerk JavaScript SDKUser Management Integration
ClerkPhoenix handles authentication only. For user management, create a plug to fetch your user data:
defmodule YourApp.UserPlug do
@behaviour Plug
def init(opts), do: opts
def call(conn, _opts) do
case conn.assigns.identity do
%{"sub" => clerk_id} ->
user = YourApp.Users.get_by_clerk_id(clerk_id)
Plug.Conn.assign(conn, :current_user, user)
nil ->
Plug.Conn.assign(conn, :current_user, nil)
end
end
end
Add this plug to your :require_auth pipeline:
pipeline :require_auth do
plug ClerkPhoenix.Plug.AuthPlug, mode: :require_auth, otp_app: :your_app
plug YourApp.UserPlug
endUsing Clerk UI Components
Clerk provides pre-built, customizable UI components that handle authentication flows. These components give you a complete authentication system without building forms from scratch.
Available Components
- SignIn: Complete sign-in form with email, social login, phone verification
- SignUp: Registration form with email verification and validation
- UserButton: User profile dropdown with account management
- UserProfile: Full profile management interface
Layout Template Setup
Update your lib/your_app_web/components/layouts/root.html.heex:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} />
<.live_title default="YourApp">
{assigns[:page_title]}
</.live_title>
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
<script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}>
</script>
<!-- Clerk JavaScript SDK -->
<script
async
crossorigin="anonymous"
data-clerk-publishable-key={@clerk_config[:publishable_key]}
src={"#{@clerk_config[:frontend_api_url]}/npm/@clerk/clerk-js@5/dist/clerk.browser.js"}
type="text/javascript"
>
</script>
<!-- Clerk Configuration -->
<script>
window.__clerk_config__ = <%= raw(Jason.encode!(@clerk_config || %{})) %>
</script>
</head>
<body>
{@inner_content}
</body>
</html>JavaScript Setup
Add to your assets/js/app.js:
// Initialize Clerk when available
window.addEventListener('load', async () => {
if (!window.Clerk) {
console.error('Clerk is not loaded')
return
}
try {
await window.Clerk.load(window.__clerk_config__)
console.log('Clerk loaded successfully')
// Mount Clerk UI components
const userButtonElement = document.getElementById('clerk-user-button')
const signInElement = document.getElementById('clerk-sign-in')
const signUpElement = document.getElementById('clerk-sign-up')
if (userButtonElement) {
window.Clerk.mountUserButton(userButtonElement)
}
if (signInElement) {
window.Clerk.mountSignIn(signInElement)
}
if (signUpElement) {
window.Clerk.mountSignUp(signUpElement)
}
} catch (error) {
console.error('Error loading Clerk:', error)
}
})Authentication Templates
Sign In Template (lib/your_app_web/controllers/page_html/sign_in.html.heex)
<div class="min-h-screen flex items-center justify-center">
<div class="max-w-md w-full space-y-8">
<div>
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
Sign in to your account
</h2>
</div>
<div id="clerk-sign-in"></div>
</div>
</div>
Sign Up Template (lib/your_app_web/controllers/page_html/sign_up.html.heex)
<div class="min-h-screen flex items-center justify-center">
<div class="max-w-md w-full space-y-8">
<div>
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
Create your account
</h2>
</div>
<div id="clerk-sign-up"></div>
</div>
</div>Protected Page Template
<div class="container mx-auto px-4 py-8">
<div class="flex justify-between items-center mb-8">
<h1 class="text-3xl font-bold">Welcome to Your Dashboard</h1>
<div id="clerk-user-button"></div>
</div>
<!-- Display authentication status -->
<div class="mb-4">
<p>Authentication Status: <strong>{if @authenticated?, do: "Signed In", else: "Not Signed In"}</strong></p>
<!-- Display user identity if available -->
<div :if={@identity}>
<h3 class="text-lg font-semibold mt-4">User Identity:</h3>
<pre class="bg-gray-100 p-4 rounded mt-2"><%= JSON.encode!(@identity, pretty: true) %></pre>
</div>
</div>
<!-- Your protected content here -->
</div>User Button in Navigation
<!-- Show content based on authentication status -->
<div :if={@authenticated?}>
<p>Welcome back!</p>
<div id="clerk-user-button"></div>
</div>
<div :if={!@authenticated?}>
<a href="/sign-in" class="btn btn-primary">Sign In</a>
</div>Template Integration Tips
Access Clerk configuration safely in templates:
<!-- Safe nested config access -->
<%= get_in(@clerk_config, [:routes, :sign_in]) || "/sign-in" %>
<%= get_in(@clerk_config, [:messages, :auth_required]) || "Please sign in" %>
<!-- Conditional rendering -->
<%= if @authenticated? do %>
<div id="clerk-user-button"></div>
<% else %>
<a href={@clerk_config[:routes][:sign_in]}>Sign In</a>
<% end %>Helper Functions
# Check authentication status
ClerkPhoenix.Plug.AuthPlug.authenticated?(conn)
# Get identity claims
ClerkPhoenix.Plug.AuthPlug.identity(conn)
# => %{"sub" => "user_123", "email" => "user@example.com", "name" => "John Doe"}
# Get authentication context
ClerkPhoenix.Plug.AuthPlug.auth_context(conn)
# => %{authenticated_at: 1640995200, session_id: "sess_123", ...}
# Get raw JWT claims (debugging)
ClerkPhoenix.Plug.AuthPlug.token_claims(conn)Advanced Configuration
Identity Mapping Configuration
Customize how identity claims are extracted from JWT tokens:
config :your_app, ClerkPhoenix,
# ... other config ...
identity_mapping: %{
subject_field: ["sub", "id", "user_id"],
email_field: ["email", "primary_email_address"],
name_field: ["name", "full_name"],
first_name_field: ["given_name", "first_name"],
last_name_field: ["family_name", "last_name"],
image_field: ["picture", "image_url", "avatar"],
organizations_field: ["org", "organizations"]
}Authentication Modes
Optional Authentication
# Tries to authenticate but continues without user if no token
plug ClerkPhoenix.Plug.AuthPlug, otp_app: :your_appRequired Authentication
# Requires authentication, redirects to sign-in on failure
plug ClerkPhoenix.Plug.AuthPlug, mode: :require_auth, otp_app: :your_appNote: Use mode: :require_auth in the options, not as a separate parameter.
Custom Failure Handling
# JSON API responses
plug ClerkPhoenix.Plug.AuthPlug,
mode: :require_auth,
on_auth_failure: :json,
otp_app: :your_app
# Custom redirect
plug ClerkPhoenix.Plug.AuthPlug,
mode: :require_auth,
redirect_path: "/custom-login",
otp_app: :your_appApplication Integration Patterns
User Management Example
defmodule YourApp.Users do
def get_by_clerk_id(clerk_id) do
Repo.get_by(User, clerk_id: clerk_id)
end
endLiveView Integration
ClerkPhoenix v0.2.0 provides first-class LiveView support. Instead of dead-view controllers, use LiveView pages with Clerk components:
Root Layout
Add the Clerk script tag to your root.html.heex:
<ClerkPhoenix.Components.clerk_script :if={assigns[:clerk_config]} config={@clerk_config} />Auth LiveViews
defmodule YourAppWeb.Auth.SignInLive do
use YourAppWeb, :live_view
use ClerkPhoenix.AuthEventHandler, callback_url: "/auth/callback"
def mount(_params, _session, socket) do
{:ok, assign(socket, page_title: "Sign In")}
end
def render(assigns) do
~H"""
<div class="flex justify-center">
<ClerkPhoenix.Components.clerk_sign_in
callback_url="/auth/callback"
sign_up_url="/auth/sign-up"
/>
</div>
"""
end
endAuth Callback Controller
The callback must remain a regular controller (LiveView WebSocket cannot read cookies set by Clerk.js):
defmodule YourAppWeb.AuthCallbackController do
use YourAppWeb, :controller
use ClerkPhoenix.AuthCallback, after_sign_in_url: "/", after_sign_out_url: "/"
endRouter
live_session :clerk_auth,
on_mount: [{ClerkPhoenix.LiveView, {:optional_auth, otp_app: :your_app}}] do
scope "/auth", YourAppWeb do
live "/sign-in", Auth.SignInLive
live "/sign-up", Auth.SignUpLive
live "/sign-out", Auth.SignOutLive
end
end
scope "/auth", YourAppWeb do
pipe_through [:browser, :require_auth]
get "/callback", AuthCallbackController, :callback
endProtected LiveViews (via on_mount)
defmodule YourAppWeb.DashboardLive do
use YourAppWeb, :live_view
def mount(_params, _session, socket) do
# ClerkPhoenix.LiveView on_mount provides:
# socket.assigns.authenticated? — boolean
# socket.assigns.identity — map with user claims
{:ok, socket}
end
endSecurity Features
- JWT Validation: Cryptographic verification using Clerk's JWKS endpoint
- Session Management: Secure session storage with size optimization
- Token Blacklisting: Support for revoked token management
- Session Fingerprinting: Protection against session hijacking
- Rate Limiting: Built-in protection against brute force attacks
Testing
Test Configuration (config/test.exs)
config :your_app, ClerkPhoenix,
publishable_key: "pk_test_test_key",
secret_key: "sk_test_test_key",
frontend_api_url: "https://test-frontend-api.clerk.accounts.dev"Testing with Authenticated Users
defmodule YourAppWeb.PageControllerTest do
use YourAppWeb.ConnCase
test "protected page requires authentication", %{conn: conn} do
conn = get(conn, ~p"/profile")
assert redirected_to(conn) == ~p"/sign-in"
end
test "protected page works with authentication", %{conn: conn} do
# Mock authenticated user
conn =
conn
|> assign(:authenticated?, true)
|> assign(:identity, %{"sub" => "user_123", "email" => "test@example.com"})
conn = get(conn, ~p"/profile")
assert html_response(conn, 200) =~ "Welcome to Your Dashboard"
end
endTroubleshooting
Common Issues
- "Clerk config not found": Ensure you've added
ClerkPhoenix.Plug.FrontendConfigPlugto your browser pipeline - "Invalid token": Check that your secret key and publishable key match your Clerk application
- "Frontend API URL not found": Verify your frontend API URL is correctly set in environment variables
- JavaScript errors: Make sure the Clerk script is loaded before trying to mount components
Debugging
Enable debug logging in development:
config :logger, level: :debugView authentication assigns in your templates:
<div style="display: none;">
<pre>Auth Status: <%= inspect(@authenticated?) %></pre>
<pre>Identity: <%= inspect(@identity) %></pre>
<pre>Auth Context: <%= inspect(@auth_context) %></pre>
</div>Security Considerations
- Never commit secrets: Keep
.envfiles out of version control - Use environment variables: All sensitive configuration should come from environment variables
- HTTPS in production: Always use HTTPS in production environments
- Validate tokens: ClerkPhoenix automatically validates JWT tokens using Clerk's JWKS endpoint
- Session security: Configure secure session settings in your Phoenix endpoint
Resources
Contributing
Contributions are welcome! Please read our contributing guidelines and submit pull requests to our GitHub repository.
License
This project is licensed under the MIT License.