Tutorial
Adding Clerk Authentication to a Full Stack Application with Django, FastAPI, and Next.js
How to add Clerk Authentication to our full stack app with Django, FastAPI and Next.js.
This tutorial extends the Full Stack Application with Django, FastAPI, and Next.js by integrating Clerk for user authentication and organization management. By the end you will have:
- Clerk-powered sign-in and sign-up pages in Next.js
- A signed webhook endpoint in FastAPI that syncs Clerk users and organizations into Django models
- PyJWT-based JWT verification so your FastAPI routes can authenticate calls from the frontend
- Django admin registration for the new models
Prerequisites: Docker Desktop installed and the base tutorial running locally (
docker compose up -dreturns no errors).
Steps
1. Set Up the Base Project
1.1 Clone the Original Tutorial Repository
git clone https://github.com/damianhodgkiss/next-django-fastapi-fullstack-tutorial.git
cd next-django-fastapi-fullstack-tutorial
1.2 Confirm Everything Runs
docker compose up -d
Open http://localhost and verify the default Next.js page loads before continuing.
2. Integrate Clerk with the Frontend
2.1 Create a Clerk Application
- Sign up or log in at clerk.com.
- Create a new application. Choose Email address + Google (or any providers you need).
- Leave the dashboard open — you will copy API keys in §4.
2.2 Install Clerk in the Frontend
docker compose exec frontend npm install @clerk/nextjs
2.3 Rebuild the Frontend Image
docker compose up --build frontend -d
2.4 Create Clerk Middleware
Clerk’s clerkMiddleware() (introduced in @clerk/nextjs v5, replacing the older authMiddleware) makes authentication state available throughout your Next.js app. By default every route is public; you opt individual routes into protection inside the middleware callback.
Create frontend/src/middleware.ts:
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
const isProtectedRoute = createRouteMatcher(['/dashboard(.*)']);
export default clerkMiddleware(async (auth, request) => {
if (isProtectedRoute(request)) {
await auth.protect();
}
});
export const config = {
matcher: [
// Skip Next.js internals and all static files
'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
// Always run for API routes
'/(api|trpc)(.*)',
],
};
Upgrading from v4? The old
authMiddlewarewas removed in@clerk/nextjsv5. Replace any existingauthMiddlewareusage withclerkMiddlewareas shown above.
2.5 Add ClerkProvider
Wrap your root layout with <ClerkProvider>. Edit frontend/src/app/layout.tsx:
import { ClerkProvider } from '@clerk/nextjs';
import { Inter } from 'next/font/google';
import './globals.css';
const inter = Inter({ subsets: ['latin'] });
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<ClerkProvider>
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
</ClerkProvider>
);
}
2.6 Create Sign-In Page
Create frontend/src/app/sign-in/[[...sign-in]]/page.tsx:
import { SignIn } from '@clerk/nextjs';
export default function SignInPage() {
return (
<div className="flex justify-center py-24">
<SignIn />
</div>
);
}
2.7 Create Sign-Up Page
Create frontend/src/app/sign-up/[[...sign-up]]/page.tsx:
import { SignUp } from '@clerk/nextjs';
export default function SignUpPage() {
return (
<div className="flex justify-center py-24">
<SignUp />
</div>
);
}
2.8 Add Sign-In / Sign-Out Buttons (Optional)
Add Clerk’s prebuilt <UserButton> and <SignInButton> to your navbar or any server component:
import { SignInButton, SignedIn, SignedOut, UserButton } from '@clerk/nextjs';
export default function Navbar() {
return (
<nav className="flex items-center justify-between p-4">
<span className="font-bold">My SaaS</span>
<SignedOut>
<SignInButton />
</SignedOut>
<SignedIn>
<UserButton />
</SignedIn>
</nav>
);
}
3. Configure the FastAPI Backend for Clerk
3.1 Update Backend Dependencies
Edit backend/requirements.txt and add:
svix==1.24.0
PyJWT[crypto]==2.8.0
svix provides the webhook signature verification helper. PyJWT[crypto] (which pulls in cryptography) is needed to verify Clerk’s RS256-signed session tokens on API requests.
Rebuild the backend services:
docker compose up --build api admin -d
3.2 Create Schemas for Clerk Webhook
Edit backend/users/schemas.py:
from pydantic import BaseModel, ConfigDict
from enum import Enum
class Organization(BaseModel):
id: int
name: str
model_config = ConfigDict(from_attributes=True)
class User(BaseModel):
id: int
email: str
model_config = ConfigDict(from_attributes=True)
class ClerkWebhookEvent(str, Enum):
USER_CREATED = "user.created"
USER_DELETED = "user.deleted"
USER_UPDATED = "user.updated"
ORGANIZATION_CREATED = "organization.created"
ORGANIZATION_DELETED = "organization.deleted"
ORGANIZATION_UPDATED = "organization.updated"
ORGANIZATION_MEMBERSHIP_CREATED = "organizationMembership.created"
ORGANIZATION_MEMBERSHIP_DELETED = "organizationMembership.deleted"
ORGANIZATION_MEMBERSHIP_UPDATED = "organizationMembership.updated"
class ClerkWebhook(BaseModel):
object: str = "event"
type: ClerkWebhookEvent
data: dict
3.3 Create a FastAPI Endpoint for the Clerk Webhook
Edit backend/users/routers.py:
from fastapi import FastAPI, APIRouter, Depends, HTTPException, status, Request
from django.conf import settings
from django.contrib.auth import get_user_model
from svix.webhooks import Webhook, WebhookVerificationError
from .schemas import (
ClerkWebhook,
ClerkWebhookEvent,
)
from .models import Organization
router = APIRouter(prefix="/auth", tags=["auth"])
async def verify_clerk_webhook(request: Request):
"""
Dependency that validates the Svix webhook signature sent by Clerk.
Raises HTTP 400 if the signature is missing or invalid.
"""
headers = request.headers
payload = await request.body()
try:
wh = Webhook(settings.CLERK_WEBHOOK_SIGNING_SECRET)
msg = wh.verify(payload, headers)
return ClerkWebhook.model_validate(msg)
except WebhookVerificationError as e:
print("Webhook verification failed:", e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid webhook signature",
)
except Exception as e:
raise e
@router.post("/clerk_webhook/")
def clerk_webhook(event: ClerkWebhook = Depends(verify_clerk_webhook)):
if event.type in [
ClerkWebhookEvent.USER_CREATED,
ClerkWebhookEvent.USER_UPDATED,
ClerkWebhookEvent.USER_DELETED,
]:
User = get_user_model()
User.handle_clerk_webhook(event)
elif event.type in [
ClerkWebhookEvent.ORGANIZATION_CREATED,
ClerkWebhookEvent.ORGANIZATION_UPDATED,
ClerkWebhookEvent.ORGANIZATION_DELETED,
ClerkWebhookEvent.ORGANIZATION_MEMBERSHIP_CREATED,
ClerkWebhookEvent.ORGANIZATION_MEMBERSHIP_UPDATED,
ClerkWebhookEvent.ORGANIZATION_MEMBERSHIP_DELETED,
]:
Organization.handle_clerk_webhook(event)
return "OK"
def register_routers(app: FastAPI):
app.include_router(router)
Edit backend/mysaas/asgi.py to register the router:
def init(app: FastAPI):
from users.routers import register_routers as register_user_routers
register_user_routers(app)
3.4 Protect API Routes with Clerk JWT Verification
Clerk issues RS256-signed JWTs. Your frontend sends the token in an Authorization: Bearer <token> header. The FastAPI backend verifies it with the PEM public key available in your Clerk dashboard under API Keys → Show JWT Public Key.
Add a reusable dependency in backend/users/auth.py:
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from django.conf import settings
security = HTTPBearer()
def get_current_user_clerk_id(
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> str:
"""
Verifies the Clerk-issued JWT and returns the Clerk user ID (sub claim).
Raises HTTP 401 for missing, expired, or tampered tokens.
"""
token = credentials.credentials
try:
payload = jwt.decode(
token,
settings.CLERK_JWT_PUBLIC_KEY, # PEM string from env
algorithms=["RS256"],
options={"verify_aud": False}, # Clerk tokens may omit aud
)
clerk_id: str = payload.get("sub")
if not clerk_id:
raise ValueError("Missing sub claim")
return clerk_id
except jwt.ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token has expired",
)
except jwt.PyJWTError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Could not validate credentials: {e}",
)
Add CLERK_JWT_PUBLIC_KEY to backend/.env.local (paste the full PEM string):
CLERK_JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
Use the dependency in any protected route:
from users.auth import get_current_user_clerk_id
from django.contrib.auth import get_user_model
@router.get("/me/")
def get_me(clerk_id: str = Depends(get_current_user_clerk_id)):
User = get_user_model()
try:
user = User.objects.get(clerk_id=clerk_id)
except User.DoesNotExist:
raise HTTPException(status_code=404, detail="User not found")
return {"id": user.id, "email": user.email}
Frontend: sending the token
In a Next.js client component, use useAuth().getToken() to retrieve the JWT and pass it to your API:
'use client';
import { useAuth } from '@clerk/nextjs';
export default function ProfileButton() {
const { getToken } = useAuth();
async function fetchMe() {
const token = await getToken();
const res = await fetch('/api/v1/auth/me/', {
headers: { Authorization: `Bearer ${token}` },
});
return res.json();
}
return <button onClick={fetchMe}>Load profile</button>;
}
4. Configure Clerk API Keys and Webhook
4.1 Set Up Clerk API Keys
- In the Clerk dashboard, go to Developers → API Keys.
- Make sure the framework dropdown shows Next.js, then click Copy.
- Paste the values into
frontend/.env.local:
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/dashboard
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/dashboard
The redirect variables tell Clerk’s components where to send users after authentication.
4.2 Set Up the Webhook Endpoint (Local Development with ngrok)
Install ngrok if needed, then expose your local port 80:
ngrok http 80
Copy the https:// hostname ngrok prints (e.g. https://7bc8-139-213-232-81.ngrok-free.app).
In the Clerk dashboard, go to Configure → Webhooks → Add Endpoint and enter:
https://7bc8-139-213-232-81.ngrok-free.app/api/v1/auth/clerk_webhook/
Subscribe to all events under user, organization, and organizationMembership.
Click the 👁 icon next to Signing Secret and copy the value, then add it to backend/.env.local:
CLERK_WEBHOOK_SIGNING_SECRET=whsec_...
Production note: In production, use your real domain instead of an ngrok URL and make sure the
CLERK_WEBHOOK_SIGNING_SECRETis set in your deployment environment (e.g. as a Docker secret or platform environment variable). Never commit secrets to version control.
4.3 Set Up the JWT Public Key
In the Clerk dashboard, go to Developers → API Keys → Show JWT Public Key. Copy the PEM block and add it to backend/.env.local:
CLERK_JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
5. Update Backend Models and Admin
5.1 Update User Model
Edit backend/users/models.py:
from django.db import models
from django_use_email_as_username.models import BaseUser, BaseUserManager
from .schemas import ClerkWebhook, ClerkWebhookEvent
class User(BaseUser):
objects = BaseUserManager()
clerk_id = models.CharField(max_length=255, blank=True, null=True, unique=True, db_index=True)
@classmethod
def handle_clerk_webhook(cls, event: ClerkWebhook):
if event.type in [
ClerkWebhookEvent.USER_CREATED,
ClerkWebhookEvent.USER_UPDATED,
]:
data = event.data
clerk_id = data.get("id")
# Look up by Clerk ID first
try:
user = cls.objects.get(clerk_id=clerk_id)
except cls.DoesNotExist:
primary_email = next(
(
email.get("email_address")
for email in data.get("email_addresses", [])
if email.get("id") == data.get("primary_email_address_id")
),
None,
)
try:
# Fall back to email lookup (handles pre-existing accounts)
user = cls.objects.get(email=primary_email)
user.clerk_id = clerk_id
except cls.DoesNotExist:
user = cls(clerk_id=clerk_id)
user.email = primary_email
user.first_name = data.get("first_name") or ""
user.last_name = data.get("last_name") or ""
user.save()
elif event.type == ClerkWebhookEvent.USER_DELETED:
data = event.data
clerk_id = data.get("id")
try:
user = cls.objects.get(clerk_id=clerk_id)
user.delete()
except cls.DoesNotExist:
pass # Already gone — idempotent
class OrganizationMembership(models.Model):
ROLE_CHOICES = [
("org:member", "Member"),
("org:admin", "Admin"),
]
user = models.ForeignKey(User, on_delete=models.CASCADE)
organization = models.ForeignKey("Organization", on_delete=models.CASCADE)
role = models.CharField(max_length=50, choices=ROLE_CHOICES, default="org:member")
def __str__(self) -> str:
return f"{self.user} — {self.role} in {self.organization}"
class Meta:
unique_together = ("user", "organization")
class Organization(models.Model):
name = models.CharField(max_length=255)
users = models.ManyToManyField(
User, through=OrganizationMembership, related_name="organizations"
)
clerk_id = models.CharField(max_length=255, blank=True, null=True, unique=True, db_index=True)
def __str__(self):
return self.name
@classmethod
def handle_clerk_webhook(cls, event: ClerkWebhook):
data = event.data
if event.type in [
ClerkWebhookEvent.ORGANIZATION_CREATED,
ClerkWebhookEvent.ORGANIZATION_UPDATED,
]:
clerk_id = data.get("id")
try:
organization = cls.objects.get(clerk_id=clerk_id)
except cls.DoesNotExist:
organization = cls(clerk_id=clerk_id)
organization.name = data.get("name", "")
organization.save()
elif event.type == ClerkWebhookEvent.ORGANIZATION_DELETED:
clerk_id = data.get("id")
try:
organization = cls.objects.get(clerk_id=clerk_id)
organization.delete()
except cls.DoesNotExist:
pass
elif event.type in [
ClerkWebhookEvent.ORGANIZATION_MEMBERSHIP_CREATED,
ClerkWebhookEvent.ORGANIZATION_MEMBERSHIP_UPDATED,
]:
org = data.get("organization")
public_user_data = data.get("public_user_data")
organization_clerk_id = org.get("id")
user_clerk_id = public_user_data.get("user_id")
role = data.get("role", "org:member")
try:
organization = cls.objects.get(clerk_id=organization_clerk_id)
user = User.objects.get(clerk_id=user_clerk_id)
OrganizationMembership.objects.update_or_create(
user=user,
organization=organization,
defaults={"role": role},
)
except cls.DoesNotExist:
pass
except User.DoesNotExist:
pass
elif event.type == ClerkWebhookEvent.ORGANIZATION_MEMBERSHIP_DELETED:
org = data.get("organization")
public_user_data = data.get("public_user_data")
organization_clerk_id = org.get("id")
user_clerk_id = public_user_data.get("user_id")
try:
organization = cls.objects.get(clerk_id=organization_clerk_id)
user = User.objects.get(clerk_id=user_clerk_id)
OrganizationMembership.objects.filter(
user=user, organization=organization
).delete()
except cls.DoesNotExist:
pass
except User.DoesNotExist:
pass
class OrganizationWithRole(Organization):
"""Proxy model used to expose a user's role alongside organization data."""
class Meta:
proxy = True
def __init__(self, *args, **kwargs):
self._role = kwargs.pop("role", None)
super().__init__(*args, **kwargs)
@property
def role(self):
return self._role
@classmethod
def from_org_and_role(cls, organization, role):
instance = cls(
**{
field.name: getattr(organization, field.name)
for field in organization._meta.fields
}
)
instance._role = role
return instance
5.2 Update Admin Interface
Edit backend/users/admin.py:
from django.contrib import admin
from django_use_email_as_username.admin import BaseUserAdmin
from .models import User, Organization, OrganizationMembership
@admin.register(User)
class UserAdmin(BaseUserAdmin):
list_display = ("email", "first_name", "last_name", "clerk_id", "is_staff")
search_fields = ("email", "clerk_id")
fieldsets = BaseUserAdmin.fieldsets + (
("Clerk", {"fields": ("clerk_id",)}),
)
class OrganizationMembershipInline(admin.TabularInline):
model = OrganizationMembership
extra = 0
@admin.register(Organization)
class OrganizationAdmin(admin.ModelAdmin):
list_display = ("name", "clerk_id")
search_fields = ("name", "clerk_id")
inlines = [OrganizationMembershipInline]
@admin.register(OrganizationMembership)
class OrganizationMembershipAdmin(admin.ModelAdmin):
list_display = ("user", "organization", "role")
list_filter = ("role",)
5.3 Create and Apply Migrations
docker compose exec api python manage.py makemigrations
docker compose exec api python manage.py migrate
6. Environment Variables Reference
Here is a complete summary of every environment variable used across the stack:
| Variable | File | Description |
|---|---|---|
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY | frontend/.env.local | Clerk publishable key (safe to expose) |
CLERK_SECRET_KEY | frontend/.env.local | Clerk secret key (keep private) |
NEXT_PUBLIC_CLERK_SIGN_IN_URL | frontend/.env.local | Path for the sign-in page |
NEXT_PUBLIC_CLERK_SIGN_UP_URL | frontend/.env.local | Path for the sign-up page |
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL | frontend/.env.local | Redirect after sign-in |
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL | frontend/.env.local | Redirect after sign-up |
CLERK_WEBHOOK_SIGNING_SECRET | backend/.env.local | Svix signing secret for webhook verification |
CLERK_JWT_PUBLIC_KEY | backend/.env.local | PEM public key for JWT verification on API routes |
7. Troubleshooting
Webhook returns 400 “Invalid webhook signature”
- Make sure ngrok is still running and the URL in the Clerk dashboard matches exactly (including the trailing slash).
- Confirm
CLERK_WEBHOOK_SIGNING_SECRETinbackend/.env.localmatches the secret shown in the Clerk dashboard. - Remember that ngrok free-tier URLs change every time you restart it — update the Clerk dashboard URL each time.
authMiddleware not found after upgrading @clerk/nextjs
authMiddlewarewas removed in@clerk/nextjsv5. Replace it withclerkMiddlewareas shown in §2.4.
JWT verification fails on the API
- Verify the PEM key in
backend/.env.localincludes the full header and footer lines. - Newlines inside the PEM string in
.envfiles must be literal\nescape sequences or a multiline string. - Check that the token has not expired — Clerk session tokens are short-lived by default.
Migrations fail
- Ensure the
usersapp is inINSTALLED_APPSinbackend/mysaas/settings.py. - Check that
clerk_iduniqueness constraints don’t conflict with existing test data.