These integration guides are not official documentation and the Strapi Support Team will not provide assistance with them.
What Is Kinde?
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.
Sign up for the Logbook, Strapi's Monthly newsletter
Why Integrate Kinde with Strapi
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:
- Credential isolation: Strapi never touches raw passwords. Kinde handles hashing, breach monitoring, brute-force protection, and credential rotation. Your Content Management System (CMS) only ever sees verified JWT tokens.
- Stateless scalability: JWTs carry claims within the token itself, so any Strapi instance can validate requests without shared session stores. No Redis, no sticky sessions, no replication headaches. This design fits well with horizontally scaling deployments, including Strapi Cloud.
- Independent auth updates: Add new login methods, enable MFA, or switch social providers in Kinde's dashboard, without redeploying Strapi. (If you're managing multiple environments, pairing this with Strapi Cloud environments can reduce release friction.)
- Computational offloading: CPU-intensive operations like password hashing and MFA verification run on Kinde's infrastructure. JWT validation in Strapi happens locally using cached public keys. There's no network round-trip per request.
- PKCE for public clients: Kinde's PKCE support means your single-page applications (SPAs) can authenticate directly without a backend proxy to hide client secrets. Strapi works cleanly with popular frontend stacks listed on Strapi's Integrations page.
- Multi-tenancy out of the box: Kinde's schema-per-tenant model pairs well with Strapi's role and permission model (see Strapi features), enabling organization-scoped access control across multiple content environments.
How to Integrate Kinde with Strapi
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.
Prerequisites
Before starting, make sure you have:
- Node.js 18+ (LTS recommended)
- Strapi v5 project initialized. If you need one:
npx create-strapi@latest my-project --quickstart- If you're deciding between self-hosting and managed hosting, Strapi's Cloud offering is worth a look for production.
- A Kinde account with an application configured at kinde.com. You need:
- A Back-end web or Single-page application created in your Kinde dashboard
- Your Client ID, Client Secret, and Domain (e.g.,
https://your-subdomain.kinde.com) - Callback URLs configured (we'll set these up in Step 2)
- Familiarity with Strapi v5's middleware system and route configuration
Step 1: Install Dependencies
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.
Step 2: Configure Environment Variables
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.
Step 3: Register Kinde as a Custom Provider
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.
Step 4: Create the JWT Verification Middleware
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).
Step 5: Register the Middleware
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.
Step 6: Configure CORS for Authenticated Requests
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.
Step 7: Add Authorization Policies
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.
Step 8: Configure the Users & Permissions Plugin
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.
Final Project Structure
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 URLProject Example: Multi-User Content Dashboard with Role-Based Access
Let'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]Setting Up the Content Types
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).
Building the Authentication Flow on the Frontend
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.
Creating Articles with Author Tracking
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>
);
}Enforcing Role-Based Access in Strapi
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.
Strapi Open Office Hours
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.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.