This guide walks you through integrating Kinde's authentication platform with Strapi v5. You'll set up JWT verification middleware, register a custom OAuth provider, synchronize users, and protect API routes (with complete code examples throughout).

Kinde is a cloud-based authentication and user management platform built for both B2B and B2C applications. It handles the heavy lifting of identity, including OAuth 2.0, OpenID Connect (OIDC), SAML, social logins, multi-factor authentication (MFA), and multi-tenancy via organizations so your application code doesn't have to.
For Strapi developers, the relevant bits are straightforward: Kinde acts as an external identity provider that issues JSON Web Tokens (JWTs). Your Strapi v5 backend verifies those tokens using Kinde's publicly available JWKS endpoint. That's the entire handshake. Kinde maintains 20+ officially maintained SDKs across backend, frontend, and mobile platforms, plus a Management REST API for programmatic user operations.
Delegating authentication to Kinde while keeping content management in Strapi creates a clean separation of concerns: identity logic lives in one place, content logic in another. Here's what that gets you:
This integration follows a JWT pass-through pattern: your frontend authenticates with Kinde, receives a JWT, and sends that token to Strapi's API. Strapi middleware verifies the token against Kinde's JWKS endpoint and grants access based on the claims inside.
If you're planning to ship this beyond local dev, it also helps to skim Strapi's broader guidance on security and auth patterns on the Strapi blog.
Before starting, make sure you have:
npx create-strapi@latest my-project --quickstarthttps://your-subdomain.kinde.com)You need the jose library for JWT verification via JWKS. It handles key caching, rotation, and RS256 validation automatically, and it's TypeScript-first with full ESM support, which aligns well with Strapi v5.
npm install joseWhy jose over jsonwebtoken? The built-in createRemoteJWKSet function eliminates manual JWKS key management entirely. It fetches Kinde's public keys, caches them, and rotates them when needed.
Add these variables to your .env file. You'll find the values in your Kinde dashboard under Settings > Applications > [Your App].
# Kinde OAuth2 Configuration
KINDE_CLIENT_ID=your_kinde_client_id
KINDE_CLIENT_SECRET=your_kinde_client_secret
KINDE_ISSUER_URL=https://your-subdomain.kinde.com
KINDE_REDIRECT_URI=http://localhost:1337/api/connect/kinde/callback
KINDE_AUTHORIZATION_URL=https://your-subdomain.kinde.com/oauth2/auth
KINDE_TOKEN_URL=https://your-subdomain.kinde.com/oauth2/token
# JWT Verification Middleware
AUTH_PROVIDER_JWKS_URL=https://your-subdomain.kinde.com/.well-known/jwks
AUTH_PROVIDER_ISSUER=https://your-subdomain.kinde.com
AUTH_PROVIDER_AUDIENCE=your_app_identifierBack in your Kinde dashboard, add http://localhost:1337/api/connect/kinde/callback to the allowed callback URLs. The callback URL registered in Kinde must exactly match what's in your Strapi config, including protocol, port, and path. This is the most common source of silent auth failures.
Strapi v5 uses a service-based registration pattern through the providers-registry service. Open src/index.js and register Kinde in the register lifecycle:
// src/index.js
module.exports = {
register({ strapi }) {
strapi
.plugin("users-permissions")
.service("providers-registry")
.add("kinde", {
icon: "key",
enabled: true,
grantConfig: {
key: process.env.KINDE_CLIENT_ID,
secret: process.env.KINDE_CLIENT_SECRET,
callback: `${strapi.config.server.url}/api/auth/kinde/callback`,
scope: ["openid", "profile", "email"],
authorize_url: process.env.KINDE_AUTHORIZATION_URL,
access_url: process.env.KINDE_TOKEN_URL,
oauth: 2,
},
async authCallback({ access_token, refresh_token, params, provider, profile, strapi }) {
// Check for existing user by email
const existingUsers = await strapi.entityService.findMany(
"plugin::users-permissions.user",
{ filters: { email: profile.email } }
);
if (existingUsers.length === 0) {
// First login: create a new Strapi user
const defaultRole = await strapi
.query("plugin::users-permissions.role")
.findOne({ where: { type: "authenticated" } });
return await strapi.entityService.create(
"plugin::users-permissions.user",
{
data: {
username:
profile.username || profile.email.split("@")[0],
email: profile.email,
provider: provider,
confirmed: true,
blocked: false,
role: defaultRole.id,
},
}
);
}
// Returning user: update and return
return await strapi.entityService.update(
"plugin::users-permissions.user",
existingUsers[0].id,
{ data: { confirmed: true, blocked: false } }
);
},
});
},
};A few things to note here. The authCallback function handles user synchronization: it creates a Strapi user on first login and updates them on subsequent logins. This uses the deprecated strapi.entityService API from Strapi v4; in Strapi v5 it is replaced by the new Document Service API, and strapi.query() was part of the older query engine pattern.
For more background on authentication patterns and tradeoffs, Strapi's own write-up on authentication and authorization is a useful reference.
This is the core of the integration. The middleware intercepts incoming requests, extracts the Bearer token, and verifies it against Kinde's JWKS endpoint.
Create src/middlewares/jwt-authentication.ts:
// src/middlewares/jwt-authentication.ts
import { jwtVerify, createRemoteJWKSet } from "jose";
export default (config, { strapi }) => {
const JWKS = createRemoteJWKSet(
new URL(process.env.AUTH_PROVIDER_JWKS_URL)
);
return async (ctx, next) => {
try {
const authHeader = ctx.request.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return ctx.unauthorized("No token provided");
}
const token = authHeader.split(" ")[1];
const { payload } = await jwtVerify(token, JWKS, {
issuer: process.env.AUTH_PROVIDER_ISSUER,
audience: process.env.AUTH_PROVIDER_AUDIENCE,
});
// Attach verified user claims to Koa context
ctx.state.user = {
id: payload.sub,
email: payload.email,
claims: payload,
};
await next();
} catch (err) {
strapi.log.error("JWT verification failed:", err);
return ctx.unauthorized("Invalid token");
}
};
};The createRemoteJWKSet function fetches Kinde's public keys from https://your-subdomain.kinde.com/.well-known/jwks, caches them in memory, and handles key rotation transparently. The jwtVerify call validates the token's signature, expiration, issuer, and audience in one step.
Setting ctx.state.user is critical. This is how downstream policies and controllers access the authenticated user.
If you're building out a larger API surface, it can be helpful to align this with Strapi's broader security guidance (see Strapi security content on the blog).
You have two options: apply the middleware globally or per-route. For most integrations, per-route is more practical since you probably have public endpoints too.
Option A: Global registration in config/middlewares.js:
// config/middlewares.js
module.exports = [
"strapi::logger",
"strapi::errors",
"strapi::security",
"strapi::cors",
"strapi::poweredBy",
"strapi::query",
"strapi::body",
"strapi::session",
"strapi::favicon",
"strapi::public",
"global::jwt-authentication",
];Order matters here. Your custom middleware should come after core Strapi middlewares like strapi::errors, strapi::security, and strapi::cors. Incorrect ordering is a common source of cryptic auth failures.
Option B: Per-route application (recommended for mixed public/protected APIs):
// src/api/article/routes/article.js
const { createCoreRouter } = require("@strapi/strapi").factories;
module.exports = createCoreRouter("api::article.article", {
config: {
find: {
auth: false, // Public (no token needed)
middlewares: [],
},
findOne: {
auth: false,
middlewares: [],
},
create: {
middlewares: ["global::jwt-authentication"],
policies: [],
},
update: {
middlewares: ["global::jwt-authentication"],
policies: [],
},
delete: {
middlewares: ["global::jwt-authentication"],
policies: [],
},
},
});This setup keeps find and findOne public while requiring Kinde authentication for write operations. The createCoreRouter factory is the standard v5 pattern for route configuration.
If you're mapping this to a real app, it's also worth browsing Strapi's features and related implementation notes on the Strapi blog to see common patterns for public vs. protected endpoints.
If your frontend runs on a different origin than Strapi (it almost certainly does), you need explicit CORS configuration. The default wildcard * origin won't work with Authorization headers. Browsers reject it per the CORS spec.
Update config/middlewares.js to replace the default CORS entry:
// config/middlewares.js (relevant section)
module.exports = [
"strapi::logger",
"strapi::errors",
"strapi::security",
{
name: "strapi::cors",
config: {
enabled: true,
origin: [
"http://localhost:3000",
"https://your-production-domain.com",
],
headers: ["Authorization", "Content-Type"],
credentials: true,
},
},
"strapi::poweredBy",
"strapi::query",
"strapi::body",
"strapi::session",
"strapi::favicon",
"strapi::public",
"global::jwt-authentication",
];Never use origin: '*' when requests include Authorization headers or cookies. This is the single most common CORS issue developers hit during auth integration. Strapi's CORS breakdown is covered well in this guide: What is CORS? Configuration guide.
Middleware handles authentication (who are you?). Policies handle authorization (what can you do?). Create a policy that checks ownership before allowing modifications:
// src/api/article/policies/is-owner.js
module.exports = (policyContext, config, { strapi }) => {
const user = policyContext.state.user;
if (!user) {
return false; // 403 Forbidden
}
// Compare the authenticated user's ID with the resource owner
const resourceId = policyContext.request.params.id;
if (user.id === resourceId) {
return true; // Allow access
}
return false;
};Then attach the policy to your route configuration:
// src/api/article/routes/article.js
const { createCoreRouter } = require("@strapi/strapi").factories;
module.exports = createCoreRouter("api::article.article", {
config: {
update: {
middlewares: ["global::jwt-authentication"],
policies: ["is-owner"],
},
delete: {
middlewares: ["global::jwt-authentication"],
policies: ["is-owner"],
},
},
});Policies return booleans. true passes control to the controller, false returns a 403 Forbidden automatically. They run before the controller executes, making them ideal for access control checks.
If you want more context on how Strapi approaches auth, roles, and policies in practice, the Strapi team's authentication and authorization article is a solid companion read.
For production deployments, enable Strapi v5's refresh token mode in config/plugins.js:
// config/plugins.js
module.exports = {
"users-permissions": {
config: {
jwtManagement: "jwt-refresh-token",
},
},
};The jwt-refresh-token mode uses short-lived access tokens (default 15 minutes) with refresh token rotation, a significant security improvement over the legacy-super-admin mode carried over from v4.
After completing all steps, your project should look like this:
project-root/
├── config/
│ ├── middlewares.js # Global middleware registration (incl. jwt-authentication)
│ ├── plugins.js # users-permissions JWT mode config
│ └── server.js
├── src/
│ ├── index.js # Kinde provider registration
│ ├── middlewares/
│ │ └── jwt-authentication.ts # JWKS-based JWT verification
│ └── api/
│ └── article/
│ ├── routes/
│ │ └── article.js # Per-route middleware/policy config
│ └── policies/
│ └── is-owner.js # Ownership authorization policy
└── .env # Kinde credentials + JWKS URLLet's build something concrete: a content dashboard where editors create and manage articles, and viewers can only read them. Kinde handles authentication, Strapi manages content, and enforces role-based access.
If you're planning to deploy this, you can run it anywhere Strapi runs, including a managed setup on Strapi Cloud.
The architecture:
[Browser/SPA] → [Kinde Auth] → [JWT Token]
↓
[SPA with JWT] → [Strapi v5 API] → [JWT Middleware] → [RBAC Check] → [Content]First, create an Article Collection Type in Strapi. You can do this via the Admin Panel or define it programmatically. (If you want a quick overview of what Strapi gives you out of the box for modeling and APIs, start with Strapi features.)
The article needs a title (text), body (rich text), author_email (text), and a status (enumeration: draft, published).
On the frontend, you authenticate with Kinde's React SDK and pass the JWT to Strapi. Here's a React component that fetches articles from your protected Strapi endpoint:
// src/components/ArticleList.jsx
import React, { useEffect, useState } from "react";
import { useKindeAuth } from "@kinde-oss/kinde-auth-react";
const STRAPI_URL = process.env.REACT_APP_STRAPI_URL || "http://localhost:1337";
export default function ArticleList() {
const { getAccessTokenSilently, isAuthenticated, user } = useKindeAuth();
const [articles, setArticles] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchArticles() {
try {
const token = await getAccessTokenSilently();
const response = await fetch(`${STRAPI_URL}/api/articles?populate=*`, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
const data = await response.json();
setArticles(data.data || []);
} catch (err) {
console.error("Failed to fetch articles:", err);
} finally {
setLoading(false);
}
}
if (isAuthenticated) {
fetchArticles();
}
}, [isAuthenticated, getAccessTokenSilently]);
if (!isAuthenticated) return <p>Please log in to view articles.</p>;
if (loading) return <p>Loading articles...</p>;
return (
<div>
<h2>Articles for {user?.email}</h2>
{articles.map((article) => (
<div key={article.id}>
<h3>{article.title}</h3>
<p>{article.body}</p>
<small>Status: {article.status}</small>
</div>
))}
</div>
);
}The @kinde-oss/kinde-auth-react SDK (v5.11.0) handles the OAuth flow, token storage, and silent renewal. getAccessTokenSilently() retrieves a valid Kinde JWT without user interaction.
Here's a component that creates articles and automatically associates them with the authenticated user:
// src/components/CreateArticle.jsx
import React, { useState } from "react";
import { useKindeAuth } from "@kinde-oss/kinde-auth-react";
const STRAPI_URL = process.env.REACT_APP_STRAPI_URL || "http://localhost:1337";
export default function CreateArticle({ onArticleCreated }) {
const { getAccessTokenSilently, user } = useKindeAuth();
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
async function handleSubmit(e) {
e.preventDefault();
const token = await getAccessTokenSilently();
const response = await fetch(`${STRAPI_URL}/api/articles`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
data: {
title,
body,
author_email: user.email,
status: "draft",
},
}),
});
if (response.ok) {
const result = await response.json();
onArticleCreated?.(result.data);
setTitle("");
setBody("");
}
}
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Article title"
required
/>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Article body"
required
/>
<button type="submit">Create Article</button>
</form>
);
}On the Strapi side, create a policy that checks the user's Kinde claims for editor permissions before allowing write operations:
// src/api/article/policies/can-create.js
module.exports = (policyContext, config, { strapi }) => {
const user = policyContext.state.user;
if (!user || !user.claims) {
return false;
}
// Check Kinde token claims for editor role
// [Kinde includes permissions in the token payload](https://docs.kinde.com)
const permissions = user.claims.permissions || [];
if (permissions.includes("articles:write")) {
return true;
}
strapi.log.warn(
`User ${user.email} attempted article creation without articles:write permission`
);
return false;
};Wire this policy into the article routes:
// src/api/article/routes/article.js
const { createCoreRouter } = require("@strapi/strapi").factories;
module.exports = createCoreRouter("api::article.article", {
config: {
find: {
middlewares: ["global::jwt-authentication"],
},
findOne: {
middlewares: ["global::jwt-authentication"],
},
create: {
middlewares: ["global::jwt-authentication"],
policies: ["can-create"],
},
update: {
middlewares: ["global::jwt-authentication"],
policies: ["can-create", "is-owner"],
},
delete: {
middlewares: ["global::jwt-authentication"],
policies: ["can-create", "is-owner"],
},
},
});Now your content dashboard enforces a complete access control chain: Kinde authenticates the user, the JWT middleware verifies their identity, and Strapi policies enforce what they're allowed to do. Editors with articles:write permissions can create and manage content. Everyone else gets read-only access.
The patterns here scale to more complex scenarios. You could add Kinde's organization-scoped tokens for multi-tenant content, use Strapi's custom route configuration for more flexible content access patterns and specialized endpoints, or implement webhook-based sync via Kinde's Management API for real-time user lifecycle events.
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 Kinde documentation.
Yes. The integration registers Kinde as a custom provider through Strapi v5's providers-registry service, which extends the built-in users-permissions plugin. The authCallback function syncs Kinde users into Strapi's user collection on first login. This means you keep Strapi's role and permission system intact while delegating authentication entirely to Kinde. Both systems work together: Kinde verifies identity, Strapi manages content access.
Strapi uses the jose library's createRemoteJWKSet function, which fetches Kinde's public signing keys from the JWKS endpoint once, then caches them locally. Subsequent token verifications happen entirely in memory using these cached keys. The library automatically handles key rotation when Kinde updates its signing keys. This means JWT validation adds negligible latency to API requests. There's no network round-trip to Kinde on each call to your Strapi endpoints.
The authCallback function in your custom provider registration handles this automatically. On each OAuth callback, it queries Strapi's user collection by email using strapi.entityService.findMany. If no matching user exists, it creates one with the authenticated role and marks them as confirmed. On subsequent logins, it finds the existing user, updates their record, and returns it. This login-triggered sync is the most common pattern and requires no additional scheduled jobs or webhook configurations.
Yes. Configure jwtManagement: 'jwt-refresh-token' in your config/plugins.js users-permissions settings. This enables short-lived access tokens for Strapi-issued tokens, but refresh token rotation requires a third-party plugin or custom implementation. For the JWT pass-through pattern where Kinde's tokens are verified directly, token refresh is handled by Kinde's frontend SDKs. The Kinde React SDK's getAccessTokenSilently() method handles client-side token renewal automatically. Both approaches support modern token lifecycle management with limited exposure windows.
Absolutely. Strapi v5's createCoreRouter factory lets you configure authentication per action. Set auth: false on find for public read access, then set auth: true on create, update, and delete actions to require authentication. You can also add policies like is-owner for fine-grained authorization. This per-route approach is more flexible than applying settings globally and is the recommended pattern for mixed-access APIs.