These integration guides are not official documentation and the Strapi Support Team will not provide assistance with them.
What Is WorkOS?
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:
- Single Sign-On (SSO) via SAML 2.0 and OpenID Connect (OIDC)
- Directory Sync for synchronizing user directories from enterprise systems using the System for Cross-domain Identity Management (SCIM) protocol
- Admin Portal so enterprise customers can self-configure their SSO and directory connections
- AuthKit — a hosted authentication UI with built-in support for email/password, magic links, social login, MFA, and enterprise SSO
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.
Sign up for the Logbook, Strapi's Monthly newsletter
Why Integrate WorkOS with Strapi
Adding WorkOS to your Strapi project bridges the gap between headless content management and enterprise identity infrastructure. Here's what you get:
- Enterprise SSO without the protocol headaches. WorkOS abstracts SAML 2.0 and OIDC so you can onboard enterprise customers who use Okta, Azure AD, or any other corporate IdP — no per-provider integration work required.
- Centralized user provisioning. WorkOS user profiles sync into Strapi's users-permissions system, keeping a single source of truth for who has access to your content API.
- Built-in multi-factor authentication (MFA) and passwordless auth. AuthKit handles MFA, magic links, and email verification out of the box, with no custom UI or token management on your end.
- Multi-tenant ready. WorkOS Organizations model workspaces natively, which maps well to Strapi projects serving multiple clients or brands from a single content architecture.
- Flexible frontend pairing. Since Strapi is headless, you can use WorkOS authentication with any frontend, such as Next.js, React, or Vue, while Strapi handles content delivery via its REST or GraphQL APIs.
- Secure session architecture. WorkOS sealed sessions use HTTP-only, secure cookies with cross-site request forgery (CSRF) protection, aligning with Strapi's security best practices.
How to Integrate WorkOS with Strapi
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.
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- A WorkOS account with API keys from the WorkOS Dashboard
- WorkOS Client ID and API Key — available in your WorkOS dashboard under the environment settings
- Basic familiarity with Strapi's backend customization patterns (controllers, services, middlewares)
- Understanding that Strapi v5 is Koa-based. All middleware uses
async (ctx, next)signatures, not Express's(req, res, next)
Step 1: Install the WorkOS SDK
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.
Step 2: Configure Environment Variables
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',
},
},
},
});Step 3: Register WorkOS as a Custom Provider
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.
Step 4: Create the Authentication Controller
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.
Step 5: Define Custom Routes
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.
Step 6: Add Route Protection Middleware
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.
Step 7: Test the Authentication Flow
Start your Strapi server and verify the integration:
npm run develop- Navigate to
http://localhost:1337/api/auth/workos/login— this should redirect you to WorkOS AuthKit's hosted login page. - Authenticate using any method AuthKit supports (email/password, magic link, social login, or enterprise SSO).
- After authentication, WorkOS redirects back to your callback URL with an authorization code.
- The callback controller exchanges the code for a user profile, creates or updates the Strapi user, and returns a JWT.
Test the JWT against a protected route:
curl -H "Authorization: Bearer YOUR_JWT_HERE" \
http://localhost:1337/api/articlesProject Example: Multi-Tenant Blog Platform with Role-Based Content Access
This 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.
Content Model Setup
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)
Organization-Scoped Service
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,
},
});
},
}));Enhanced Auth Controller with Organization Context
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,
},
});
}Organization-Scoped Controller
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 };
},
}));Role-Based Policy for Content Operations
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.
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 (6:30 pm to 7:30 pm UTC): Strapi Discord Open Office Hours.
For more details, visit the Strapi documentation and WorkOS 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.