This guide walks you through integrating Clerk authentication with Strapi v5, covering middleware setup, JWT verification, webhook configuration, and user synchronization to build secure, content-driven applications

Clerk is a developer-focused authentication platform that handles user management, session handling, and authentication flows for modern web applications. Rather than building authentication infrastructure from scratch, developers can use Clerk's prebuilt UI components, SDKs, and APIs to add secure sign-in functionality.
The platform supports multiple authentication methods out of the box: email verification codes, magic links, phone authentication via SMS, passkeys, social login through OAuth providers, and traditional username/password combinations. Clerk also provides enterprise features like Single Sign-On (SSO) and Multi-Factor Authentication (MFA) without requiring custom development.
For backend integrations, Clerk issues short-lived JWT session tokens that can be verified server-side. This token-based approach fits well with headless CMS architectures where authentication happens on the client while content APIs run separately.
Combining Clerk's authentication capabilities with Strapi's content management features creates a powerful foundation for building secure, content-driven applications.
Here's what this integration enables:
This integration uses the strapi-plugin-clerk-auth approach, which provides the most complete solution with built-in JWT verification using Clerk's secret key via the @clerk/backend SDK, user synchronization via webhooks with Svix signature verification, and route protection middleware including both JWT authentication and user ownership enforcement.
Before starting, confirm your environment meets these requirements:
npx create-strapi@latest my-project --quickstartNavigate to your Strapi project's plugins directory and clone the authentication plugin:
cd src/plugins
git clone https://github.com/PaulBratslavsky/strapi-plugin-clerk-auth.git clerk-auth
cd clerk-auth
npm installThe plugin installs two critical dependencies:
@clerk/backend for JWT verification using Clerk's SDKsvix for webhook signature verificationAdd the plugin to your Strapi configuration. Create or update config/plugins.ts:
export default {
'clerk-auth': {
enabled: true,
resolve: './src/plugins/clerk-auth'
},
};This registers the Clerk authentication plugin in your Strapi v5 project, enabling JWT verification middleware and user synchronization functionality.
Add your Clerk credentials to the .env file at your project root:
CLERK_SECRET_KEY=sk_test_your_secret_key_here
CLERK_WEBHOOK_SECRET=whsec_your_webhook_secret_hereYou can find your Secret Key in the Clerk Dashboard under API Keys. The webhook secret comes later when you configure the webhook endpoint.
Webhooks keep Strapi's user database in sync with Clerk user events. When someone signs up, updates their profile, or deletes their account in Clerk, webhooks notify Strapi to create, update, or delete the corresponding user record in Strapi's users-permissions table. The webhook handler verifies signatures using the Svix library and stores the clerkId and user information from Clerk events.
The plugin exposes a webhook endpoint at /api/clerk-auth/webhook. Configure it in your Clerk Dashboard:
https://your-strapi-domain.com/api/clerk-auth/webhookuser.created, user.updated, user.deleted.env file as CLERK_WEBHOOK_SECRETFor local development, use a tunneling service like ngrok to expose your local Strapi instance:
ngrok http 1337Then use the ngrok URL as your webhook endpoint.
The plugin provides two middleware functions for route protection: clerk-auth middleware for JWT verification and user authentication, and is-user-owner middleware for ownership-based access control.
Apply these middlewares to routes that require authentication. Create or modify route files in src/api/[apiName]/routes/:
export default {
routes: [
{
method: 'GET',
path: '/users/me',
handler: 'user.me',
config: {
middlewares: ['plugin::clerk-auth.clerk-auth'],
auth: false,
},
},
{
method: 'PUT',
path: '/users/:id',
handler: 'user.update',
config: {
middlewares: [
'plugin::clerk-auth.clerk-auth',
'plugin::clerk-auth.is-user-owner'
],
auth: false,
},
},
],
};Setting auth: false is critical—it prevents conflicts between Clerk's JWT verification and Strapi's built-in authentication system.
Your frontend application needs to obtain tokens from Clerk and include them in API requests to Strapi. Here's how that looks:
// Get the session token from Clerk
const token = await clerk.session.getToken();
// Include it in requests to your Strapi API
const response = await fetch('https://your-strapi-api.com/api/users/me', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
const userData = await response.json();The Clerk SDKs for React, Next.js, and other frameworks provide hooks and components that simplify token retrieval and authentication integration with Strapi.
Rebuild and restart your Strapi server:
npm run build
npm run developTest the integration by:
A membership content platform demonstrates how Clerk and Strapi work together in practice. Users authenticate through Clerk to access premium articles, courses, or resources managed in Strapi.
The system has three main components:
When a user signs up, Clerk's webhook notifies Strapi to create a user record. The frontend then requests protected content by including the Clerk JWT in API calls. Strapi's middleware verifies the token and checks permissions before returning content.
Create a content type for membership content in Strapi. Using the Content-Type Builder, define an Article type with these fields:
title (Text)content (Rich Text)tier (Enumeration: free, basic, premium)author (Relation to User)Create a custom controller that filters content based on user membership tier:
// src/api/article/controllers/article.ts
export default {
async findAccessible(ctx) {
const user = ctx.state.user;
if (!user) {
// Return only free content for unauthenticated users
return await strapi.documents('api::article.article').findMany({
filters: { tier: 'free' },
populate: ['author'],
});
}
// Get user's membership tier from metadata
const userTier = user.membershipTier || 'free';
// Define accessible tiers based on membership level
const accessibleTiers = {
free: ['free'],
basic: ['free', 'basic'],
premium: ['free', 'basic', 'premium'],
};
return await strapi.documents('api::article.article').findMany({
filters: {
tier: { $in: accessibleTiers[userTier] }
},
populate: ['author'],
});
},
};Register the protected route with Clerk authentication middleware:
// src/api/article/routes/custom.ts
// Protected route for accessing public articles without Clerk authentication
export default {
routes: [
{
method: 'GET',
path: '/articles/accessible',
handler: 'article.findAccessible',
config: {
// Apply Clerk JWT verification middleware from the strapi-plugin-clerk-auth
middlewares: ['plugin::clerk-auth.clerk-auth'],
// Disable Strapi's built-in authentication to prevent JWT validation conflicts
// Clerk's custom middleware handles token verification instead
auth: false,
},
},
],
};Extend the webhook handler to sync membership tier data from Clerk's user metadata:
// In your webhook handler
case 'user.updated':
const existingUser = await strapi.db
.query('plugin::users-permissions.user')
.findOne({ where: { clerkId: evt.data.id } });
if (existingUser) {
await strapi.db.query('plugin::users-permissions.user').update({
where: { id: existingUser.id },
data: {
clerkId: evt.data.id,
fullName: `${evt.data.first_name} ${evt.data.last_name}`,
// Optional: map custom metadata from Clerk public_metadata
...(evt.data.public_metadata?.tier && { membershipTier: evt.data.public_metadata.tier }),
},
});
}
break;On the frontend, fetch accessible content using the authenticated user's token:
import { useAuth } from '@clerk/clerk-react';
function ArticleList() {
const { getToken } = useAuth();
const [articles, setArticles] = useState([]);
useEffect(() => {
async function fetchArticles() {
const token = await getToken();
const response = await fetch(
`${process.env.STRAPI_URL}/api/articles/accessible`,
{
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
}
);
const data = await response.json();
setArticles(data);
}
fetchArticles();
}, [getToken]);
return (
<div>
{articles.map((article) => (
<article key={article.id}>
<h2>{article.title}</h2>
<span>{article.tier}</span>
</article>
))}
</div>
);
}This pattern scales to other content-driven applications: e-commerce platforms with protected checkout, blogs with subscriber-only content, or SaaS applications with tiered feature access.
If you have any questions about Strapi 5 or just would like to stop by and say hi, you can join us at Strapi's Discord Open Office Hours, Monday through Friday, from 12:30 pm to 1:30 pm CST: Strapi Discord Open Office Hours.
For more details, visit the Strapi documentation and Clerk documentation.
CORS errors occur when your frontend domain isn't allowed to make requests to your Strapi API. Configure the strapi::cors middleware in config/middlewares.js by adding your frontend domain to the origin array and including Authorization in the headers array. Set credentials: true if using cookies. Avoid using wildcard (*) origins when credentials are enabled—this combination is blocked by browsers. Centralize your CORS configuration in one location to prevent duplicate header conflicts.
Strapi's default JWT validation expects tokens in its own format, not Clerk's. The solution is using custom middleware with Clerk's @clerk/backend SDK to verify tokens instead of Strapi's built-in validation. To troubleshoot token validation issues, confirm your Authorization header follows the Bearer <token> format exactly, check that your CLERK_SECRET_KEY environment variable is set correctly, and verify the token hasn't expired by checking Clerk's session logs. Setting auth: false on protected routes is critical—it disables Strapi's default authentication system to prevent middleware conflicts between Clerk's JWT verification and Strapi's built-in validation.
Configure webhooks in Clerk Dashboard by navigating to Webhooks, clicking 'Add Endpoint,' and entering your Strapi webhook URL: https://your-domain.com/api/clerk-auth/webhook. Subscribe to user.created, user.updated, and user.deleted events. Copy the signing secret to your .env file as CLERK_WEBHOOK_SECRET. For local development, use ngrok to create a public tunnel to your local Strapi instance. The plugin's Svix integration automatically verifies webhook signatures for security.
Yes, you can use Strapi's role-based access control (RBAC) system with Clerk authentication. The integration provides seamless user synchronization between Clerk and Strapi's role-based access control system. When Strapi receives a verified Clerk JWT, it automatically maps the Clerk user to the Strapi user by email or creates new users automatically in Strapi's users-permissions table.
Strapi's permission system functions normally without additional authorization logic. According to Strapi's documentation on external JWT authentication, developers can successfully override Strapi's default authentication to accept Clerk's external JWTs while maintaining the users-permissions plugin functionality. The is-user-owner middleware enforces ownership validation by comparing the authenticated user's ID against resource ownership, preventing unauthorized access to user-specific resources while respecting role-based permissions.
Additionally, the integration uses webhook-driven updates to keep user data synchronized in real-time, with webhook security ensuring data integrity through cryptographic signature verification. This allows you to manage roles and permissions through Strapi's native RBAC system while using Clerk for centralized authentication management.
The plugin approach provides a complete solution with built-in JWT verification, user synchronization via webhooks, ownership checks, and route protection—ideal for getting to production quickly. Custom middleware gives you full control over the authentication flow but requires more code and ongoing maintenance. Start with the plugin unless you have specific requirements it doesn't address. You can always extend the plugin or migrate to custom middleware later as your needs evolve.