DH
โ€”11 min read

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.

djangofastapinextjsclerk

Adding Clerk Authentication to a Full Stack Application 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 -d returns 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

  1. Sign up or log in at clerk.com.
  2. Create a new application. Choose Email address + Google (or any providers you need).
  3. 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 authMiddleware was removed in @clerk/nextjs v5. Replace any existing authMiddleware usage with clerkMiddleware as 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

  1. In the Clerk dashboard, go to Developers โ†’ API Keys.
  2. Make sure the framework dropdown shows Next.js, then click Copy.
  3. 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_SECRET is 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:

VariableFileDescription
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEYfrontend/.env.localClerk publishable key (safe to expose)
CLERK_SECRET_KEYfrontend/.env.localClerk secret key (keep private)
NEXT_PUBLIC_CLERK_SIGN_IN_URLfrontend/.env.localPath for the sign-in page
NEXT_PUBLIC_CLERK_SIGN_UP_URLfrontend/.env.localPath for the sign-up page
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URLfrontend/.env.localRedirect after sign-in
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URLfrontend/.env.localRedirect after sign-up
CLERK_WEBHOOK_SIGNING_SECRETbackend/.env.localSvix signing secret for webhook verification
CLERK_JWT_PUBLIC_KEYbackend/.env.localPEM 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_SECRET in backend/.env.local matches 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

  • authMiddleware was removed in @clerk/nextjs v5. Replace it with clerkMiddleware as shown in ยง2.4.

JWT verification fails on the API

  • Verify the PEM key in backend/.env.local includes the full header and footer lines.
  • Newlines inside the PEM string in .env files must be literal \n escape sequences or a multiline string.
  • Check that the token has not expired โ€” Clerk session tokens are short-lived by default.

Migrations fail

  • Ensure the users app is in INSTALLED_APPS in backend/mysaas/settings.py.
  • Check that clerk_id uniqueness constraints don't conflict with existing test data.
Damian Hodgkiss

Damian Hodgkiss

Senior Staff Engineer at Sumo Group, leading development of AppSumo marketplace. Technical solopreneur with 25+ years of experience building SaaS products.

Creating Freedom

Join me on the journey from engineer to solopreneur. Learn how to build profitable SaaS products while keeping your technical edge.

    Proven strategies

    Learn the counterintuitive ways to find and validate SaaS ideas

    Technical insights

    From choosing tech stacks to building your MVP efficiently

    Founder mindset

    Transform from engineer to entrepreneur with practical steps