Learn how to integrate WorkOS with Strapi v5 for enterprise SSO, magic links, and MFA. Step-by-step guide with working code examples and user provisioning.

WorkOS is an enterprise authentication and user management platform designed for B2B SaaS applications. It acts as a Service Provider (SP) in authentication flows, letting your application integrate with corporate Identity Providers (IdPs) like Okta, Azure AD, and Google Workspace through a single integration point.
The platform's core features include:
Rather than building and maintaining separate integrations for every IdP your enterprise customers use, WorkOS gives you one SDK that handles all the protocol complexity behind the scenes.
Adding WorkOS to your Strapi project bridges the gap between headless content management and enterprise identity infrastructure. Here's what you get:
This section walks through registering WorkOS as a custom authentication provider in Strapi v5, handling the OAuth callback, provisioning users, and protecting your API routes.
Before starting, make sure you have:
npx create-strapi@latest my-project --quickstartasync (ctx, next) signatures, not Express's (req, res, next)From your Strapi project root, install the WorkOS Node.js SDK:
npm install @workos-inc/node@8No additional session or cookie packages are needed — Strapi's Koa internals handle request/response context, and you'll issue Strapi JWTs after authentication rather than managing WorkOS sealed sessions directly.
Add the following to your .env file in the Strapi project root:
WORKOS_API_KEY=sk_example_123456789
WORKOS_CLIENT_ID=client_123456789
WORKOS_REDIRECT_URI=http://localhost:1337/api/auth/workos/callback
WORKOS_COOKIE_PASSWORD=<32-character-secure-password>Replace the placeholder values with your actual credentials from the WorkOS dashboard. The WORKOS_REDIRECT_URI must correspond to a redirect URI that is configured in your WorkOS project settings; WorkOS also supports wildcard characters (*) in redirect URIs instead of requiring an exact string match in all cases.
You can reference these in Strapi's configuration files using the env() helper:
// config/plugins.js
module.exports = ({ env }) => ({
'users-permissions': {
config: {
jwt: {
expiresIn: '30d',
},
},
},
});Custom providers in Strapi v5 must be registered in the register lifecycle, not bootstrap. This is a common source of errors. Configure WorkOS as an authentication provider using the Strapi admin panel under Settings > Users & Permissions > Providers, following the official Strapi Users & Permissions providers documentation.
// src/index.js
const { WorkOS } = require('@workos-inc/node');
module.exports = {
register({ strapi }) {
const workos = new WorkOS(process.env.WORKOS_API_KEY, {
clientId: process.env.WORKOS_CLIENT_ID,
});
strapi
.plugin('users-permissions')
.service('providers-registry')
.add('workos', {
icon: 'key',
enabled: true,
grantConfig: {
key: process.env.WORKOS_CLIENT_ID,
secret: process.env.WORKOS_API_KEY,
callback: process.env.WORKOS_REDIRECT_URI,
scope: ['openid', 'profile', 'email'],
authorize_url: 'https://api.workos.com/sso/authorize',
access_url: 'https://api.workos.com/sso/token',
oauth: 2,
},
async authCallback({ access_token, providers, purest, strapi }) {
// WorkOS SSO returns a profile after code exchange
// We handle the actual exchange in a custom controller (Step 4)
// This callback processes the resulting user data
return {
username: access_token.email,
email: access_token.email,
firstName: access_token.firstName || '',
lastName: access_token.lastName || '',
};
},
});
},
bootstrap({ strapi }) {
// Bootstrap runs after register — use for runtime setup, not provider registration
},
};The provider name 'workos' in .add('workos', ...) determines the callback route path. This name must match what you reference in your routes and controllers.
Build a custom controller that initiates the WorkOS login flow and handles the OAuth callback. Create a new file:
// src/api/auth/controllers/workos-auth.js
const { WorkOS } = require('@workos-inc/node');
const workos = new WorkOS(process.env.WORKOS_API_KEY, {
clientId: process.env.WORKOS_CLIENT_ID,
});
module.exports = {
async login(ctx) {
const authorizationUrl = workos.userManagement.getAuthorizationUrl({
provider: 'authkit',
redirectUri: process.env.WORKOS_REDIRECT_URI,
clientId: process.env.WORKOS_CLIENT_ID,
});
ctx.redirect(authorizationUrl);
},
async callback(ctx) {
const { code } = ctx.query;
if (!code) {
ctx.status = 400;
ctx.body = { error: 'Missing authorization code' };
return;
}
try {
const authResponse = await workos.userManagement.authenticateWithCode({
clientId: process.env.WORKOS_CLIENT_ID,
code,
session: {
sealSession: true,
cookiePassword: process.env.WORKOS_COOKIE_PASSWORD,
},
});
const { user: workosUser } = authResponse;
// Normalize email to lowercase to avoid case-sensitivity issues
const normalizedEmail = workosUser.email.toLowerCase();
// Upsert user in Strapi's users-permissions system
const strapiUser = await findOrCreateUser(normalizedEmail, workosUser);
// Issue a Strapi JWT for subsequent API requests
const jwt = strapi.plugin('users-permissions').service('jwt').issue({
id: strapiUser.id,
});
ctx.body = {
jwt,
user: {
id: strapiUser.id,
username: strapiUser.username,
email: strapiUser.email,
},
};
} catch (error) {
strapi.log.error('WorkOS authentication failed:', error.message);
ctx.status = 401;
ctx.body = { error: 'Authentication failed' };
}
},
};
async function findOrCreateUser(email, workosProfile) {
const existingUser = await strapi.query('plugin::users-permissions.user').findOne({
where: { email },
});
if (existingUser) {
// Update profile data on returning login
await strapi.query('plugin::users-permissions.user').update({
where: { id: existingUser.id },
data: {
username: workosProfile.firstName
? `${workosProfile.firstName} ${workosProfile.lastName || ''}`.trim()
: existingUser.username,
},
});
return existingUser;
}
// First-time login — create a new Strapi user
const defaultRole = await strapi.query('plugin::users-permissions.role').findOne({
where: { type: 'authenticated' },
});
return strapi.query('plugin::users-permissions.user').create({
data: {
username: workosProfile.firstName
? `${workosProfile.firstName} ${workosProfile.lastName || ''}`.trim()
: email.split('@')[0],
email,
provider: 'workos',
confirmed: true,
role: defaultRole.id,
},
});
}The findOrCreateUser function implements the upsert pattern — it checks for existing users by email before creating a new record. Note the email normalization: Azure AD and other IdPs sometimes return mixed-case addresses, which can cause failed lookups if you don't normalize.
Create the route configuration that maps URLs to your controller:
// src/api/auth/routes/workos-auth.js
module.exports = {
routes: [
{
method: 'GET',
path: '/auth/workos/login',
handler: 'workos-auth.login',
config: {
auth: false, // Public — initiates the login flow
policies: [],
middlewares: [],
},
},
{
method: 'GET',
path: '/auth/workos/callback',
handler: 'workos-auth.callback',
config: {
auth: false, // Public — receives the OAuth callback
policies: [],
middlewares: [],
},
},
],
};Both routes set auth: false because they handle unauthenticated users. The login route redirects to WorkOS AuthKit, and the callback route receives the authorization code and issues a Strapi JWT.
Now that users can authenticate through WorkOS and receive Strapi JWTs, you can protect your content API routes. Create a custom policy that checks authentication state:
// src/policies/is-authenticated.js
module.exports = (policyContext, config, { strapi }) => {
if (policyContext.state.user) {
return true;
}
return false; // Returns 403 Forbidden
};Apply it to specific content-type routes. For example, if you have an article content type:
// src/api/article/routes/article.js
const { createCoreRouter } = require('@strapi/strapi').factories;
module.exports = createCoreRouter('api::article.article', {
config: {
find: {
auth: false, // Public read access
policies: [],
middlewares: [],
},
findOne: {
auth: false, // Public read access
policies: [],
middlewares: [],
},
create: {
auth: true, // Requires JWT
policies: ['global::is-authenticated'], // Verify user exists
middlewares: [],
},
update: {
auth: true,
policies: ['global::is-authenticated'],
middlewares: [],
},
delete: {
auth: true,
policies: ['global::is-authenticated'],
middlewares: [],
},
},
});Strapi's request execution order processes global middlewares first, then route-specific middlewares, then the authentication check (if auth: true), then policies, and finally the controller handler.
Start your Strapi server and verify the integration:
npm run develophttp://localhost:1337/api/auth/workos/login — this should redirect you to WorkOS AuthKit's hosted login page.Test the JWT against a protected route:
curl -H "Authorization: Bearer YOUR_JWT_HERE" \
http://localhost:1337/api/articlesThis example demonstrates a blog platform where different organizations manage their own content. WorkOS handles authentication and organization membership, while Strapi manages content and role-based access.
The architecture separates concerns clearly: WorkOS owns identity (who is this user, which organization do they belong to), and Strapi owns content (what can they see and edit). This maps to the management vs. delivery authentication pattern used across enterprise headless CMS deployments.
In the Strapi Admin Panel, create a blog-post Collection Type with these fields:
title (Text)content (Rich Text)slug (UID, attached to title)organizationId (Text — stores the WorkOS organization ID)status (Enumeration: draft, published, archived)Create a custom service that filters content by organization:
// src/api/blog-post/services/blog-post.js
const { createCoreService } = require('@strapi/strapi').factories;
module.exports = createCoreService('api::blog-post.blog-post', ({ strapi }) => ({
async findByOrganization(organizationId, params = {}) {
const results = await strapi.entityService.findMany('api::blog-post.blog-post', {
...params,
filters: {
...params.filters,
organizationId,
},
});
return results;
},
async createForOrganization(organizationId, data) {
return strapi.entityService.create('api::blog-post.blog-post', {
data: {
...data,
organizationId,
},
});
},
}));Extend the callback controller from Step 4 to capture organization membership from WorkOS:
// src/api/auth/controllers/workos-auth.js
const { WorkOS } = require('@workos-inc/node');
const workos = new WorkOS(process.env.WORKOS_API_KEY, {
clientId: process.env.WORKOS_CLIENT_ID,
});
module.exports = {
async login(ctx) {
const { organization } = ctx.query;
const params = {
provider: 'authkit',
redirectUri: process.env.WORKOS_REDIRECT_URI,
clientId: process.env.WORKOS_CLIENT_ID,
};
// If an organization ID is provided, scope login to that org
if (organization) {
params.organization = organization;
}
const authorizationUrl = workos.userManagement.getAuthorizationUrl(params);
ctx.redirect(authorizationUrl);
},
async callback(ctx) {
const { code } = ctx.query;
if (!code) {
ctx.status = 400;
ctx.body = { error: 'Missing authorization code' };
return;
}
try {
const authResponse = await workos.userManagement.authenticateWithCode({
clientId: process.env.WORKOS_CLIENT_ID,
code,
session: {
sealSession: true,
cookiePassword: process.env.WORKOS_COOKIE_PASSWORD,
},
});
const { user: workosUser } = authResponse; // organizationId is not a top-level field on authResponse
const normalizedEmail = workosUser.email.toLowerCase();
const strapiUser = await findOrCreateUser(normalizedEmail, workosUser);
// Issue JWT with organization context embedded
const jwt = strapi.plugin('users-permissions').service('jwt').issue({
id: strapiUser.id,
organizationId: organizationId || null,
});
ctx.body = {
jwt,
user: {
id: strapiUser.id,
username: strapiUser.username,
email: strapiUser.email,
organizationId: organizationId || null,
},
};
} catch (error) {
strapi.log.error('WorkOS authentication failed:', error.message);
ctx.status = 401;
ctx.body = { error: 'Authentication failed' };
}
},
};
async function findOrCreateUser(email, workosProfile) {
const existingUser = await strapi.query('plugin::users-permissions.user').findOne({
where: { email },
});
if (existingUser) {
return existingUser;
}
const defaultRole = await strapi.query('plugin::users-permissions.role').findOne({
where: { type: 'authenticated' },
});
return strapi.query('plugin::users-permissions.user').create({
data: {
username: workosProfile.firstName
? `${workosProfile.firstName} ${workosProfile.lastName || ''}`.trim()
: email.split('@')[0],
email,
provider: 'workos',
confirmed: true,
role: defaultRole.id,
},
});
}Build a controller that uses the JWT's organization context to filter content:
// src/api/blog-post/controllers/blog-post.js
const { createCoreController } = require('@strapi/strapi').factories;
module.exports = createCoreController('api::blog-post.blog-post', ({ strapi }) => ({
async find(ctx) {
const organizationId = ctx.state.user?.organizationId;
if (!organizationId) {
// No org context — return public posts only
ctx.query = {
...ctx.query,
filters: { ...ctx.query?.filters, status: 'published' },
};
return super.find(ctx);
}
// Org-scoped — return all posts for this organization
const posts = await strapi
.service('api::blog-post.blog-post')
.findByOrganization(organizationId, {
filters: ctx.query?.filters,
populate: ctx.query?.populate,
});
ctx.body = { data: posts };
},
async create(ctx) {
const organizationId = ctx.state.user?.organizationId;
if (!organizationId) {
ctx.status = 403;
ctx.body = { error: 'Organization membership required to create posts' };
return;
}
const post = await strapi
.service('api::blog-post.blog-post')
.createForOrganization(organizationId, ctx.request.body.data);
ctx.body = { data: post };
},
}));Add a policy that checks whether a user can modify content within their organization:
// src/policies/can-manage-org-content.js
module.exports = async (policyContext, config, { strapi }) => {
const { user } = policyContext.state;
if (!user || !user.organizationId) return false;
// For update/delete, verify the content belongs to the user's org
const { id } = policyContext.params;
if (id) {
const post = await strapi.entityService.findOne('api::blog-post.blog-post', id);
if (!post || post.organizationId !== user.organizationId) {
return false;
}
}
return true;
};Apply it to the routes:
// src/api/blog-post/routes/blog-post.js
const { createCoreRouter } = require('@strapi/strapi').factories;
module.exports = createCoreRouter('api::blog-post.blog-post', {
config: {
find: {
auth: false,
policies: [],
},
findOne: {
auth: false,
policies: [],
},
create: {
auth: true,
policies: ['global::can-manage-org-content'],
},
update: {
auth: true,
policies: ['global::can-manage-org-content'],
},
delete: {
auth: true,
policies: ['global::can-manage-org-content'],
},
},
});This structure gives each organization an isolated content space. Users authenticate through WorkOS, receive organization-scoped JWTs from Strapi, and can only create or modify content within their organization. Public readers access published content without authentication, a clean separation between content management and content delivery.
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 (6:30 pm to 7:30 pm UTC): Strapi Discord Open Office Hours.
For more details, visit the Strapi documentation and WorkOS documentation.
SSO for the Strapi admin panel is an Enterprise Edition feature. SSO options for the Strapi admin login screen are configured in the /config/admin file (or ./config/admin.js in v4), and SSO is available with Strapi Enterprise plans or as an add‑on feature. However, SSO for end users of your application, via the users-permissions plugin and custom providers as shown in this guide, is available in the community edition.
Yes, when you use WorkOS AuthKit's hosted UI, multi-factor authentication is handled entirely on WorkOS's side. AuthKit manages first-time MFA factor enrollment, OTP validation, and the full challenge flow during login. Your Strapi backend only receives the authenticated user profile after MFA succeeds, so no custom MFA code is needed in your Strapi project. MFA requirements are configured per environment in your WorkOS dashboard.
Always normalize email addresses to lowercase before querying or creating users in Strapi. Corporate IdPs like Azure AD sometimes return mixed-case email addresses, which causes failed user lookups on subsequent logins. Add workosProfile.email.toLowerCase() in your callback handler's findOrCreateUser function. This prevents duplicate accounts and authentication failures, as it's a documented issue across Strapi SSO integrations.
Use OpenID Connect (OIDC) for new integrations. It's the modern standard with simpler JSON-based setup and better alignment with Strapi's existing OAuth 2.0 patterns. Choose SAML 2.0 only when an enterprise customer's IdP specifically requires it, typically on-premise Okta deployments or legacy Azure AD configurations. WorkOS supports both protocols, so your Strapi integration code stays the same regardless of which protocol the end customer's IdP uses.
Absolutely. Since Strapi is headless, it serves content through REST or GraphQL APIs independent of your frontend. Your Next.js (or React, Vue, or any other) frontend initiates the WorkOS login, receives the Strapi JWT from the callback, and includes that token in subsequent API requests to Strapi's content API. This architecture cleanly separates authentication, content management, and presentation layers.