Customizing Users & Permissions plugin routes
Page summary:
Users & Permissions feature exposes
/usersand/authroutes that can be extended or overridden using the plugin extension system. This guide shows how to add custom policies, override controllers, and add new routes to the User collection.
The Users & Permissions feature ships with built-in routes for authentication (/auth) and user management (/users). Because these routes belong to a plugin rather than a user-created content-type, they cannot be customized with createCoreRouter. Instead, extend them through the plugin extension system using a strapi-server file in the /src/extensions/users-permissions/ folder.
How it works
Users & Permissions uses a route array and controller objects that differ from standard content-types. Understanding their structure is essential before customizing them.
Route structure
Unlike content-types you create (e.g., api::restaurant.restaurant), the Users & Permissions plugin registers its routes inside the plugin.routes['content-api'].routes array. This array contains all /users, /auth, /roles, and /permissions route definitions.
Each route is an object with the following shape:
{
method: 'GET', // HTTP method
path: '/users', // URL path (relative to /api)
handler: 'user.find', // controller.action
config: {
prefix: '', // path prefix (empty means /api)
},
}
Route configurations can also include optional policies and middlewares arrays (see Add a custom policy).
The strapi-server extension file
All customizations to the Users & Permissions plugin go in a single file:
- JavaScript
- TypeScript
module.exports = (plugin) => {
// Your customizations here
return plugin;
};
export default (plugin) => {
// Your customizations here
return plugin;
};
The function receives the full plugin object and must return the plugin. You can modify plugin.routes, plugin.controllers, plugin.policies, and plugin.services before returning.
Available actions
The user controller is a plain object that exposes the following actions:
| Action | Method | Path | Description |
|---|---|---|---|
user.count | GET | /users/count | Count users |
user.find | GET | /users | Find all users |
user.me | GET | /users/me | Get authenticated user |
user.findOne | GET | /users/:id | Find one user |
user.create | POST | /users | Create a user |
user.update | PUT | /users/:id | Update a user |
user.destroy | DELETE | /users/:id | Delete a user |
The auth controller is a factory function ({ strapi }) => ({...}) that exposes the following actions:
| Action | Method | Path | Rate limited |
|---|---|---|---|
auth.callback | POST | /auth/local | Yes |
auth.callback | GET | /auth/:provider/callback | No |
auth.register | POST | /auth/local/register | Yes |
auth.connect | GET | /connect/(.*) | Yes |
auth.forgotPassword | POST | /auth/forgot-password | Yes |
auth.resetPassword | POST | /auth/reset-password | Yes |
auth.changePassword | POST | /auth/change-password | Yes |
auth.emailConfirmation | GET | /auth/email-confirmation | No |
auth.sendEmailConfirmation | POST | /auth/send-email-confirmation | No |
auth.refresh | POST | /auth/refresh | No |
auth.logout | POST | /auth/logout | No |
Because the user and auth controllers have different types (plain object vs. factory function), they require different override patterns (see Override a user controller action and Override an auth controller action).
Customize routes
You can add policies, register new endpoints, or remove existing ones by modifying the plugin.routes['content-api'].routes array in the extension file.
Add a custom policy
A common requirement is restricting who can update or delete user accounts: for example, ensuring users can only update their own profile.
1. Create the policy file
Create a global policy that checks whether the authenticated user matches the target user. The policy function receives the Koa context (with access to state.user and params), an optional configuration object, and { strapi }:
- JavaScript
- TypeScript
"use strict";
module.exports = (policyContext, config, { strapi }) => {
const currentUser = policyContext.state.user;
if (!currentUser) {
return false;
}
const targetUserId = Number(policyContext.params.id);
if (currentUser.id !== targetUserId) {
return false;
}
return true;
};
export default (policyContext, config, { strapi }) => {
const currentUser = policyContext.state.user;
if (!currentUser) {
return false;
}
const targetUserId = Number(policyContext.params.id);
if (currentUser.id !== targetUserId) {
return false;
}
return true;
};
The is-own-user policy above applies specifically to Users & Permissions plugin routes. For a similar pattern on standard content-types (restricting access to the entry author), see the is-owner middleware example and the is-owner-review policy example.
2. Attach the policy to the user routes
In the plugin extension file, find the update and delete routes and add the policy:
- JavaScript
- TypeScript
module.exports = (plugin) => {
// Find the routes that need the policy
const routes = plugin.routes['content-api'].routes;
// Add the 'is-own-user' policy to the update route
const updateRoute = routes.find(
(route) => route.handler === 'user.update'
);
if (updateRoute) {
updateRoute.config = updateRoute.config || {};
updateRoute.config.policies = updateRoute.config.policies || [];
updateRoute.config.policies.push('global::is-own-user');
}
// Add the same policy to the delete route
const deleteRoute = routes.find(
(route) => route.handler === 'user.destroy'
);
if (deleteRoute) {
deleteRoute.config = deleteRoute.config || {};
deleteRoute.config.policies = deleteRoute.config.policies || [];
deleteRoute.config.policies.push('global::is-own-user');
}
return plugin;
};
export default (plugin) => {
// Find the routes that need the policy
const routes = plugin.routes['content-api'].routes;
// Add the 'is-own-user' policy to the update route
const updateRoute = routes.find(
(route) => route.handler === 'user.update'
);
if (updateRoute) {
updateRoute.config = updateRoute.config || {};
updateRoute.config.policies = updateRoute.config.policies || [];
updateRoute.config.policies.push('global::is-own-user');
}
// Add the same policy to the delete route
const deleteRoute = routes.find(
(route) => route.handler === 'user.destroy'
);
if (deleteRoute) {
deleteRoute.config = deleteRoute.config || {};
deleteRoute.config.policies = deleteRoute.config.policies || [];
deleteRoute.config.policies.push('global::is-own-user');
}
return plugin;
};
With this configuration, PUT /api/users/:id and DELETE /api/users/:id return a 403 Forbidden error if the authenticated user does not match the :id in the URL.
For a more informative error message, throw a PolicyError instead of returning false:
const { errors } = require('@strapi/utils');
const { PolicyError } = errors;
// Inside the policy:
throw new PolicyError('You can only modify your own account');
For more details on policy patterns and error handling, see the policies documentation.
Add a new route
You can add custom routes to the Users & Permissions plugin. For example, add an endpoint that deactivates a user account as follows:
- JavaScript
- TypeScript
module.exports = (plugin) => {
// Add a new controller action
plugin.controllers.user.deactivate = async (ctx) => {
const { id } = ctx.params;
const user = await strapi
.plugin('users-permissions')
.service('user')
.edit(id, { blocked: true });
ctx.body = { message: `User ${user.username} has been deactivated` };
};
// Register the route
plugin.routes['content-api'].routes.push({
method: 'POST',
path: '/users/:id/deactivate',
handler: 'user.deactivate',
config: {
prefix: '',
policies: ['global::is-own-user'],
},
});
return plugin;
};
export default (plugin) => {
// Add a new controller action
plugin.controllers.user.deactivate = async (ctx) => {
const { id } = ctx.params;
const user = await strapi
.plugin('users-permissions')
.service('user')
.edit(id, { blocked: true });
ctx.body = { message: `User ${user.username} has been deactivated` };
};
// Register the route
plugin.routes['content-api'].routes.push({
method: 'POST',
path: '/users/:id/deactivate',
handler: 'user.deactivate',
config: {
prefix: '',
policies: ['global::is-own-user'],
},
});
return plugin;
};
After restarting Strapi, POST /api/users/:id/deactivate becomes available. Grant the corresponding permission in the admin panel under Users & Permissions plugin > Roles for the roles that should access this endpoint.
Remove a route
You can disable a route by filtering it out of the routes array. For example, disable the user count endpoint as follows:
- JavaScript
- TypeScript
module.exports = (plugin) => {
plugin.routes['content-api'].routes = plugin.routes['content-api'].routes.filter(
(route) => route.handler !== 'user.count'
);
return plugin;
};
export default (plugin) => {
plugin.routes['content-api'].routes = plugin.routes['content-api'].routes.filter(
(route) => route.handler !== 'user.count'
);
return plugin;
};
Override controllers
Beyond route-level customizations, you can override the controller actions themselves to change how the plugin handles requests. The user and auth controllers use different patterns, so each requires a specific approach.
Override a user controller action
The user controller is a plain object, so you can directly read and replace its methods in the extension file. For instance, to add custom logic to the me endpoint:
- JavaScript
- TypeScript
module.exports = (plugin) => {
const originalMe = plugin.controllers.user.me;
plugin.controllers.user.me = async (ctx) => {
// Call the original controller
await originalMe(ctx);
// Add extra data to the response
if (ctx.body) {
ctx.body.timestamp = new Date().toISOString();
}
};
return plugin;
};
export default (plugin) => {
const originalMe = plugin.controllers.user.me;
plugin.controllers.user.me = async (ctx) => {
// Call the original controller
await originalMe(ctx);
// Add extra data to the response
if (ctx.body) {
ctx.body.timestamp = new Date().toISOString();
}
};
return plugin;
};
When wrapping a controller, always call the original function first to preserve the default behavior. Skipping the original function means you take over the full request handling, including sanitization and error handling.
Override an auth controller action
The auth controller uses a factory pattern: it exports a function ({ strapi }) => ({...}) instead of a plain object. When your extension code runs, Strapi has not yet resolved this factory. As a result, plugin.controllers.auth is a function, not an object with methods.
To override an auth action, wrap the factory itself:
- JavaScript
- TypeScript
module.exports = (plugin) => {
const originalAuthFactory = plugin.controllers.auth;
plugin.controllers.auth = ({ strapi }) => {
// Resolve the original factory to get the controller methods
const originalAuth = originalAuthFactory({ strapi });
// Override the register method
const originalRegister = originalAuth.register;
originalAuth.register = async (ctx) => {
// Call the original register logic
await originalRegister(ctx);
// Custom post-registration logic
if (ctx.body && ctx.body.user) {
strapi.log.info(`New user registered: ${ctx.body.user.email}`);
}
};
return originalAuth;
};
return plugin;
};
export default (plugin) => {
const originalAuthFactory = plugin.controllers.auth;
plugin.controllers.auth = ({ strapi }) => {
// Resolve the original factory to get the controller methods
const originalAuth = originalAuthFactory({ strapi });
// Override the register method
const originalRegister = originalAuth.register;
originalAuth.register = async (ctx) => {
// Call the original register logic
await originalRegister(ctx);
// Custom post-registration logic
if (ctx.body && ctx.body.user) {
strapi.log.info(`New user registered: ${ctx.body.user.email}`);
}
};
return originalAuth;
};
return plugin;
};
Do not access plugin.controllers.auth.register directly. Because auth is a factory function at extension time, its methods are not accessible until Strapi calls the factory. Always wrap the factory as shown above.
Full example
The following example combines several customizations in a single file: it adds a policy to update and delete, wraps the me controller, and adds a new profile route.
- JavaScript
- TypeScript
module.exports = (plugin) => {
const routes = plugin.routes['content-api'].routes;
// 1. Add 'is-own-user' policy to update and delete
for (const route of routes) {
if (route.handler === 'user.update' || route.handler === 'user.destroy') {
route.config = route.config || {};
route.config.policies = route.config.policies || [];
route.config.policies.push('global::is-own-user');
}
}
// 2. Wrap the 'me' controller to include the user's role
const originalMe = plugin.controllers.user.me;
plugin.controllers.user.me = async (ctx) => {
await originalMe(ctx);
if (ctx.state.user && ctx.body) {
const user = await strapi
.plugin('users-permissions')
.service('user')
.fetch(ctx.state.user.id, { populate: ['role'] });
ctx.body.role = user.role;
}
};
// 3. Add a custom route
plugin.controllers.user.profile = async (ctx) => {
const user = await strapi
.plugin('users-permissions')
.service('user')
.fetch(ctx.state.user.id, { populate: ['role'] });
ctx.body = {
username: user.username,
email: user.email,
role: user.role?.name,
createdAt: user.createdAt,
};
};
routes.push({
method: 'GET',
path: '/users/profile',
handler: 'user.profile',
config: { prefix: '' },
});
return plugin;
};
export default (plugin) => {
const routes = plugin.routes['content-api'].routes;
// 1. Add 'is-own-user' policy to update and delete
for (const route of routes) {
if (route.handler === 'user.update' || route.handler === 'user.destroy') {
route.config = route.config || {};
route.config.policies = route.config.policies || [];
route.config.policies.push('global::is-own-user');
}
}
// 2. Wrap the 'me' controller to include the user's role
const originalMe = plugin.controllers.user.me;
plugin.controllers.user.me = async (ctx) => {
await originalMe(ctx);
if (ctx.state.user && ctx.body) {
const user = await strapi
.plugin('users-permissions')
.service('user')
.fetch(ctx.state.user.id, { populate: ['role'] });
ctx.body.role = user.role;
}
};
// 3. Add a custom route
plugin.controllers.user.profile = async (ctx) => {
const user = await strapi
.plugin('users-permissions')
.service('user')
.fetch(ctx.state.user.id, { populate: ['role'] });
ctx.body = {
username: user.username,
email: user.email,
role: user.role?.name,
createdAt: user.createdAt,
};
};
routes.push({
method: 'GET',
path: '/users/profile',
handler: 'user.profile',
config: { prefix: '' },
});
return plugin;
};
Validation
After making changes, restart Strapi and verify your customizations:
- Run
yarn strapi routes:listto confirm your new or modified routes appear. - Test protected routes without authentication to verify policies return
403 Forbidden. - Test with an authenticated user to confirm the expected behavior.
- Check the Strapi server logs for errors during startup.
Troubleshooting
| Symptom | Possible cause |
|---|---|
| Route not found (404) | The new route was not pushed to plugin.routes['content-api'].routes, or its prefix property is missing. |
| Policy not applied | The policy name is incorrect. Global policies require the global:: prefix (e.g., global::is-own-user). |
| Controller returns 500 | The controller action name does not match the handler value in the route definition. |
| Changes not reflected | Strapi was not restarted after modifying the extension file. Extensions are loaded at startup. |
| Permission denied (403) | The new action is not enabled for the role. Enable it in Users & Permissions plugin > Roles. |
Cannot read property of auth controller | The auth controller is a factory function, not a plain object. Wrap the factory instead of accessing methods directly (see Override an auth controller action). |