Skip to main content

Customizing Users & Permissions plugin routes

Page summary:

Users & Permissions feature exposes /users and /auth routes 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.

Prerequisites

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:

/src/extensions/users-permissions/strapi-server.js
module.exports = (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:

ActionMethodPathDescription
user.countGET/users/countCount users
user.findGET/usersFind all users
user.meGET/users/meGet authenticated user
user.findOneGET/users/:idFind one user
user.createPOST/usersCreate a user
user.updatePUT/users/:idUpdate a user
user.destroyDELETE/users/:idDelete a user

The auth controller is a factory function ({ strapi }) => ({...}) that exposes the following actions:

ActionMethodPathRate limited
auth.callbackPOST/auth/localYes
auth.callbackGET/auth/:provider/callbackNo
auth.registerPOST/auth/local/registerYes
auth.connectGET/connect/(.*)Yes
auth.forgotPasswordPOST/auth/forgot-passwordYes
auth.resetPasswordPOST/auth/reset-passwordYes
auth.changePasswordPOST/auth/change-passwordYes
auth.emailConfirmationGET/auth/email-confirmationNo
auth.sendEmailConfirmationPOST/auth/send-email-confirmationNo
auth.refreshPOST/auth/refreshNo
auth.logoutPOST/auth/logoutNo
Note

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 }:

/src/policies/is-own-user.js
"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;
};
Tip

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:

/src/extensions/users-permissions/strapi-server.js
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;
};

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.

Tip

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:

/src/extensions/users-permissions/strapi-server.js
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;
};

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:

/src/extensions/users-permissions/strapi-server.js
module.exports = (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:

/src/extensions/users-permissions/strapi-server.js
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;
};
Caution

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:

/src/extensions/users-permissions/strapi-server.js
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;
};
Caution

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.

/src/extensions/users-permissions/strapi-server.js
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;
};

Validation

After making changes, restart Strapi and verify your customizations:

  1. Run yarn strapi routes:list to confirm your new or modified routes appear.
  2. Test protected routes without authentication to verify policies return 403 Forbidden.
  3. Test with an authenticated user to confirm the expected behavior.
  4. Check the Strapi server logs for errors during startup.

Troubleshooting

SymptomPossible 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 appliedThe policy name is incorrect. Global policies require the global:: prefix (e.g., global::is-own-user).
Controller returns 500The controller action name does not match the handler value in the route definition.
Changes not reflectedStrapi 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 controllerThe auth controller is a factory function, not a plain object. Wrap the factory instead of accessing methods directly (see Override an auth controller action).