Learn how to integrate Auth0 with Strapi v5 for OAuth login, callbacks, user sync, and protected content access. Practical steps with code examples.

Auth0 is a managed identity access management (IAM) platform built on OAuth 2.0 and OpenID Connect. It acts as an authorization server: your application delegates authentication to Auth0 rather than handling credentials, sessions, and security policies directly.
Auth0 provides Universal Login (a hosted login UI), social identity provider connectors, multi-factor authentication (MFA), and role-based access control. It issues JWT access tokens and ID tokens that your backend validates to authorize API requests.
For full-stack developers, the practical value is straightforward: adding social login, MFA, or passwordless authentication requires configuration changes in Auth0's dashboard, not application code changes.
Strapi v5's Users & Permissions plugin supports Auth0 out of the box, which means you can wire up a production-grade auth flow without building custom OAuth middleware. Here's what that combination gives you:
This section walks through the complete setup: from creating your Auth0 application to making authenticated Strapi API calls. The integration uses Strapi v5's Users & Permissions plugin and its built-in OAuth provider flow.
Before starting, confirm you have the following:
npx create-strapi@latest my-project --quickstart if you need a fresh onehttp://localhost:3000Verify your Strapi version is v5 by checking package.json:
{
"dependencies": {
"@strapi/strapi": "^5.37.1"
}
}Log into the Auth0 Dashboard and navigate to Applications → Create Application. Select Regular Web Application. This is required for server-side OAuth flows where Strapi handles the callback.
Record your Domain, Client ID, and Client Secret from the application settings page.
Configure these URLs in your Auth0 application settings:
| Setting | Value |
|---|---|
| Allowed Callback URLs | http://localhost:1337/api/connect/auth0/callback |
| Allowed Logout URLs | http://localhost:3000 |
| Allowed Web Origins | http://localhost:3000 |
The callback URL follows the pattern <strapi-backend-url>/api/connect/<provider>/callback. This is where Auth0 sends the authorization code after the user authenticates.
Next, open Advanced Settings → Grant Types and enable all four:
Per the Strapi Auth0 documentation, all four grant types must be enabled for the provider to function correctly.
Add your Auth0 credentials to the .env file in your Strapi project root:
AUTH0_DOMAIN=your-auth0-tenant.auth0.com
AUTH0_CLIENT_ID=your-auth0-client-id
AUTH0_CLIENT_SECRET=your-auth0-client-secretStrapi also needs to know its own public URL to construct correct callback URLs. Update /config/server.js:
module.exports = ({ env }) => ({
host: env('HOST', '0.0.0.0'),
port: env.int('PORT', 1337),
url: env('PUBLIC_URL', 'http://localhost:1337'),
});The url property must align with the domain and port used for your OAuth callback endpoint, and Auth0 will return a visible 'Callback URL mismatch' error if the callback URL does not exactly match one of the Allowed Callback URLs.
Start your Strapi server and navigate to http://localhost:1337/admin/settings/users-permissions/providers. Find Auth0 in the provider list and configure it:
| Field | Value | Notes |
|---|---|---|
| Enable | ON | Activates the provider |
| Client ID | Your Auth0 Client ID | From Auth0 application settings |
| Client Secret | Your Auth0 Client Secret | From Auth0 application settings |
| Subdomain | e.g., my-tenant.eu | For https://my-tenant.eu.auth0.com/, enter my-tenant.eu |
| Redirect URL | http://localhost:3000/connect/auth0 | Your frontend callback route |
The Subdomain field needs only the tenant portion. If your Auth0 domain is https://my-tenant.eu.auth0.com/, enter my-tenant.eu, not the full URL.
The Redirect URL is where Strapi sends the user after processing the OAuth callback. This points to your frontend application, which will extract the Strapi JWT from the query parameters.
In Strapi v5, some provider configurations are managed through the admin UI (such as Users & Permissions OAuth providers), while others still rely on environment variables in configuration files (such as email and media providers). For automated or Infrastructure-as-Code deployments, plan for an admin UI setup step or implement a bootstrap script using Strapi's internal API.
Strapi v5 introduces a refresh-token mode for JWT management. Set this in /config/plugins.js:
module.exports = {
'users-permissions': {
config: {
jwt: {
expiresIn: '7d',
},
jwtManagement: 'refresh-token',
},
},
};The refresh-token mode is recommended for new projects. A legacy-support option exists for teams migrating from v4, but you should avoid it for fresh builds.
You can also configure rate limiting on auth endpoints:
module.exports = {
'users-permissions': {
config: {
jwt: {
expiresIn: '7d',
},
jwtManagement: 'refresh-token',
rateLimit: {
enabled: true,
max: 100,
windowMs: 60000,
},
},
},
};The OAuth flow starts when your frontend redirects the user to Strapi's connect endpoint. Strapi then handles the redirect to Auth0's Universal Login page.
// Login button handler in your frontend application
function handleLogin() {
window.location.href = 'http://localhost:1337/api/connect/auth0';
}Here's what happens behind the scenes:
http://localhost:1337/api/connect/auth0http://localhost:1337/api/connect/auth0/callback with an authorization codeAfter the OAuth flow completes, Strapi redirects to the URL you configured in Step 3 (e.g., http://localhost:3000/connect/auth0) with the Strapi JWT appended as an access_token query parameter.
Create a callback handler in your frontend:
// /connect/auth0 page or route handler
async function handleAuth0Callback() {
const params = new URLSearchParams(window.location.search);
const strapiToken = params.get('access_token');
if (!strapiToken) {
console.error('No access_token found in callback URL');
return;
}
// Store the Strapi JWT for subsequent API calls
localStorage.setItem('strapiToken', strapiToken);
// Fetch the authenticated user's profile from Strapi
const userRes = await fetch('http://localhost:1337/api/users/me', {
headers: {
Authorization: `Bearer ${strapiToken}`,
},
});
const user = await userRes.json();
console.log('Authenticated user:', user);
// Redirect to your app's main page
window.location.href = '/';
}
handleAuth0Callback();The access_token in the query parameter is a Strapi JWT, not an Auth0 token. Strapi issues its own JWT after verifying the OAuth callback, and this is what you use for all subsequent Strapi API calls.
With the Strapi JWT stored, you can now access protected content:
async function fetchProtectedContent() {
const strapiToken = localStorage.getItem('strapiToken');
const response = await fetch('http://localhost:1337/api/articles', {
headers: {
Authorization: `Bearer ${strapiToken}`,
'Content-Type': 'application/json',
},
});
const { data } = await response.json();
return data;
}Strapi's Users & Permissions plugin automatically validates the JWT and assigns the user's role. Content access is determined by the permissions you've configured for the Authenticated role in the Admin Panel under Settings → Users & Permissions → Roles.
Strapi automatically creates a user record on first OAuth login. If you need to store additional Auth0 metadata, like the Auth0 user ID (sub claim), extend the user model and add sync logic.
First, add an auth0Id field to the User content type via the Admin Panel or by editing the schema directly.
Then create a sync controller using the Document Service API:
// src/api/user-sync/controllers/user-sync.js
module.exports = {
async syncUser(ctx) {
const { auth0Id, email, name } = ctx.request.body;
// Look up existing user by Auth0 ID using Document Service API
const existingUsers = await strapi
.documents('plugin::users-permissions.user')
.findMany({
filters: { auth0Id: auth0Id },
});
if (existingUsers.length > 0) {
// Update existing user
const user = existingUsers[0];
const updated = await strapi
.documents('plugin::users-permissions.user')
.update({
documentId: user.documentId,
data: {
username: name || email,
email,
},
});
return ctx.send(updated);
}
// Create new user
const defaultRole = await strapi
.documents('plugin::users-permissions.role')
.findMany({
filters: { type: 'authenticated' },
});
const newUser = await strapi
.documents('plugin::users-permissions.user')
.create({
data: {
auth0Id,
email,
username: name || email,
provider: 'auth0',
confirmed: true,
blocked: false,
},
});
return ctx.send(newUser);
},
};Register the route in src/api/user-sync/routes/user-sync.js:
module.exports = {
routes: [
{
method: 'POST',
path: '/users/sync',
handler: 'user-sync.syncUser',
config: {
policies: [],
middlewares: [],
},
},
],
};The Document Service API replaces the deprecated Entity Service from Strapi v4 when migrating to Strapi v5. Documents are identified by documentId instead of id, and relations, media fields, and components are not auto-populated. You must specify them explicitly with the populate parameter.
This project combines Auth0, Strapi v5, and Next.js to build a content platform where articles are only accessible to authenticated users. Unauthenticated visitors see a login prompt, while logged-in users access premium content based on their role.
┌─────────────────┐ OAuth Flow ┌─────────────────┐
│ │ ──────────────────► │ │
│ Next.js App │ │ Auth0 │
│ (localhost:3000)│ ◄────────────────── │ ([Authorization ](https://auth0.com/docs/get-started/identity-fundamentals/introduction-to-auth0)│
│ │ ID + Access Token │ Server) │
└────────┬────────┘ └──────────────────┘
│
│ Strapi JWT (via /api/connect/auth0)
▼
┌─────────────────┐
│ Strapi v5 │
│ (localhost:1337) │
│ Content + RBAC │
└─────────────────┘In the Strapi Admin Panel, create an Article Collection Type with these fields:
title (Text, required)content (Rich Text)tier (Enumeration: free, premium)author (Relation: belongs to User)Then configure permissions under Settings → Users & Permissions → Roles:
find and findOne on Articles (filtered by tier = free)find, findOne, create, and update on ArticlesInstall the Auth0 Next.js SDK:
npm install @auth0/nextjs-auth0Add environment variables to .env.local:
AUTH0_SECRET=a-long-random-value-at-least-32-characters
AUTH0_BASE_URL=http://localhost:3000
AUTH0_ISSUER_BASE_URL=https://your-tenant.auth0.com
AUTH0_CLIENT_ID=your-client-id
AUTH0_CLIENT_SECRET=your-client-secret
NEXT_PUBLIC_STRAPI_URL=http://localhost:1337Initialize Auth0 in lib/auth0.ts using the Auth0 Next.js SDK quickstart configuration:
import { Auth0Client } from '@auth0/nextjs-auth0/server';
export const auth0 = new Auth0Client();Create a profile component that conditionally renders based on authentication state:
// components/AuthNav.js
'use client';
import { useUser } from '@auth0/nextjs-auth0/client';
export default function AuthNav() {
const { user, isLoading } = useUser();
if (isLoading) return <div>Loading...</div>;
if (!user) {
return (
<nav>
<a href="http://localhost:1337/api/connect/auth0">
Log in with Auth0
</a>
</nav>
);
}
return (
<nav>
<span>Welcome, {user.name}</span>
<a href="/api/auth/logout">Log out</a>
</nav>
);
}Create the callback page that extracts the Strapi JWT:
// app/connect/auth0/page.js
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
export default function Auth0Callback() {
const router = useRouter();
const [status, setStatus] = useState('Authenticating...');
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const accessToken = params.get('access_token');
if (!accessToken) {
setStatus('Authentication failed: no token received.');
return;
}
localStorage.setItem('strapiToken', accessToken);
// Fetch user profile from Strapi
fetch(`${process.env.NEXT_PUBLIC_STRAPI_URL}/api/users/me`, {
headers: { Authorization: `Bearer ${accessToken}` },
})
.then((res) => res.json())
.then((user) => {
localStorage.setItem('strapiUser', JSON.stringify(user));
router.push('/dashboard');
})
.catch(() => {
setStatus('Failed to fetch user profile.');
});
}, [router]);
return <div>{status}</div>;
}Build an articles page that uses the Strapi JWT to access role-restricted content:
// app/dashboard/articles/page.js
'use client';
import { useEffect, useState } from 'react';
export default function Articles() {
const [articles, setArticles] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const token = localStorage.getItem('strapiToken');
if (!token) {
window.location.href = '/';
return;
}
fetch(
`${process.env.NEXT_PUBLIC_STRAPI_URL}/api/articles?populate=author`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
}
)
.then((res) => res.json())
.then(({ data }) => {
setArticles(data);
setLoading(false);
})
.catch((err) => {
console.error('Failed to fetch articles:', err);
setLoading(false);
});
}, []);
if (loading) return <div>Loading articles...</div>;
return (
<div>
<h1>Your Articles</h1>
{articles.map((article) => (
<article key={article.documentId}>
<h2>{article.title}</h2>
<span>Tier: {article.tier}</span>
</article>
))}
</div>
);
}Create a custom policy that filters content based on the authenticated user's role. In current Strapi versions (v4/v5), a policy should use the (policyContext, config, { strapi }) signature:
// src/policies/is-authenticated.js
module.exports = async (policyContext, config, { strapi }) => {
if (policyContext.state.user) {
return true;
}
return false;
};Apply the policy and add tier-based filtering in a custom controller:
// src/api/article/controllers/article.js
const { createCoreController } = require('@strapi/strapi').factories;
module.exports = createCoreController('api::article.article', ({ strapi }) => ({
async find(ctx) {
const { user } = ctx.state;
if (!user) {
// Public users only see free articles
ctx.query.filters = { ...ctx.query.filters, tier: 'free' };
}
if (user?.role?.name === 'Author') {
// Authors see only their own articles
ctx.query.filters = { ...ctx.query.filters, author: user.id };
}
const { data, meta } = await super.find(ctx);
return { data, meta };
},
}));Add route-level protection so unauthenticated users can't access /dashboard paths:
// middleware.js
import { NextResponse } from 'next/server';
export function middleware(req) {
// Check for Strapi token in cookies or redirect to login
const token = req.cookies.get('strapiToken');
if (!token) {
return NextResponse.redirect(new URL('/', req.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*'],
};This gives you a working three-layer architecture: Auth0 handles identity, Next.js manages the session bridge, and Strapi enforces content access through its RBAC permissions. You can extend this pattern with Strapi webhooks to trigger notifications on content changes, or add custom middleware for request logging and analytics.
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, at 6:00 am and 12:30 pm CST on the Strapi Community Discord: Strapi Discord Open Office Hours.
For more details, visit the Strapi documentation and Auth0 documentation.
Yes, but Strapi Admin Panel login with Auth0 is a separate configuration from the Users & Permissions provider used for application users. You need Strapi Enterprise plan or SSO add-on support to set up admin SSO. In practice, most teams keep these flows separate: Auth0 handles Admin Panel login for internal staff through SSO, while /api/connect/auth0 handles Auth0 with Strapi login for frontend users and API consumers.
For a standard Auth0 with Strapi integration, use a Regular Web Application in Auth0. That matches the server-side Authorization Code Flow Strapi expects when it receives the callback and exchanges the code. If your frontend authenticates directly with Auth0 instead of going through Strapi's provider flow, you would usually choose a SPA application. Keep Management API access in a separate machine-to-machine app to avoid mixing concerns.
Duplicate users after Auth0 login usually happen because Strapi's provider flow can hit a race condition, documented in GitHub issue #25113. If near-simultaneous requests reach the lookup-and-create step, Strapi may create two records for the same identity. The safest fix is a unique database constraint on a stable field such as auth0Id or email. It also helps to add controlled retries and error handling around any custom user sync logic.
Strapi automatically creates a basic user profile the first time someone logs in with Auth0, including standard fields such as email, username, and provider. It does not continuously sync richer Auth0 profile data like avatars, phone numbers, app metadata, or custom claims on every login. If you need ongoing Auth0 user sync in Strapi, add a custom endpoint with the Document Service API or trigger updates from an Auth0 post-login Action.
To validate Auth0 tokens in Strapi, use JWKS-based JWT verification rather than calling /userinfo on every request. JWKS lets your backend fetch Auth0's public keys, cache them, and verify token signatures locally, which avoids extra latency and rate-limit headaches. You still need to check claims such as iss, aud, and exp. In many Auth0 with Strapi setups, though, your app uses the Strapi-issued JWT after login instead of the raw Auth0 access token.