Skip to main content

Middleware and Policies

Middleware and policies sit between the incoming request and the controller. Policies decide whether a request is allowed (yes/no). Middleware can modify the request or response (transform, log, cache, etc.).


Policies​

Policies are route-level guards. Return true to allow the request, return false to stop with 403, or throw a Strapi error for a custom status/message. If a policy returns nothing, Strapi treats it as allowed, so return false explicitly when denying a request. Policies can read and mutate the underlying Koa context, but keep them focused on access control so middleware remains responsible for request/response transformations.

Creating a policy​

// src/policies/is-authenticated.js
module.exports = (policyContext, config, { strapi }) => {
if (policyContext.state.user) {
return true;
}
return false; // returns 403
};

Is-owner policy​

One of the most common requirements - only allow the creator to edit/delete their content:

// src/policies/is-owner.js
module.exports = async (policyContext, config, { strapi }) => {
const user = policyContext.state.user;
const entryId = policyContext.params.id;

if (!user || !entryId) {
return false;
}

// Use the contentType from config, defaulting to the route's content type
const uid = config.contentType || 'api::article.article';

const entry = await strapi.documents(uid).findOne({
documentId: entryId,
populate: ['createdBy'],
});

if (!entry) {
return false;
}

// Compare the authenticated user with the entry creator
return user.id === entry.createdBy?.id;
};

Applying policies to routes​

// src/api/article/routes/article.js
const { createCoreRouter } = require('@strapi/strapi').factories;

module.exports = createCoreRouter('api::article.article', {
config: {
update: {
policies: [
// Global policy (from src/policies/)
'global::is-owner',
],
},
delete: {
policies: [
{
name: 'global::is-owner',
config: { contentType: 'api::article.article' },
},
],
},
},
});

API-level policy​

// src/api/article/policies/has-draft-access.js
module.exports = (policyContext, config, { strapi }) => {
const user = policyContext.state.user;

// Only editors and admins can access drafts
const allowedRoles = ['editor', 'admin'];
const userRole = user?.role?.type;

if (!allowedRoles.includes(userRole)) {
return false;
}

return true;
};

// Reference in route config as: 'api::article.has-draft-access'

Custom policy errors​

Use PolicyError from @strapi/utils when a denied request needs a clearer message than the default 403:

const { errors } = require('@strapi/utils');
const { PolicyError } = errors;

module.exports = (policyContext) => {
if (!policyContext.state.user) {
throw new PolicyError('You must be signed in to access this route.');
}

return true;
};

Route middleware​

Route middleware runs for specific routes. It can modify the request and response.

Response time middleware​

// src/api/article/middlewares/response-time.js
module.exports = (config, { strapi }) => {
return async (ctx, next) => {
const start = Date.now();
await next();
const duration = Date.now() - start;
ctx.set('X-Response-Time', `${duration}ms`);

if (duration > (config.slowThreshold || 1000)) {
strapi.log.warn(`Slow request: ${ctx.method} ${ctx.url} took ${duration}ms`);
}
};
};

Applying route middleware​

// src/api/article/routes/article.js
const { createCoreRouter } = require('@strapi/strapi').factories;

module.exports = createCoreRouter('api::article.article', {
config: {
find: {
middlewares: [
{
name: 'api::article.response-time',
config: { slowThreshold: 500 },
},
],
},
},
});

Caching middleware​

// src/api/article/middlewares/cache.js
const cache = new Map();

module.exports = (config, { strapi }) => {
const ttl = config.ttl || 60000; // default 60 seconds

return async (ctx, next) => {
// Only cache GET requests
if (ctx.method !== 'GET') {
return next();
}

const key = ctx.url;
const cached = cache.get(key);

if (cached && Date.now() - cached.timestamp < ttl) {
ctx.body = cached.body;
ctx.set('X-Cache', 'HIT');
return;
}

await next();

if (ctx.status === 200) {
cache.set(key, { body: ctx.body, timestamp: Date.now() });
ctx.set('X-Cache', 'MISS');
}
};
};

Global middleware​

Global middleware runs on every request. Register it in config/middlewares.js.

Request logging middleware​

// src/middlewares/request-logger.js
module.exports = (config, { strapi }) => {
return async (ctx, next) => {
const start = Date.now();
await next();
const duration = Date.now() - start;

strapi.log.info({
method: ctx.method,
url: ctx.url,
status: ctx.status,
duration: `${duration}ms`,
ip: ctx.ip,
userAgent: ctx.get('User-Agent'),
});
};
};

Registering global middleware​

// config/middlewares.js
module.exports = [
'strapi::logger',
'strapi::errors',
'strapi::security',
'strapi::cors',
'strapi::poweredBy',
'strapi::query',
'strapi::body',
'strapi::session',
'strapi::favicon',
'strapi::public',

// Add your custom global middleware
'global::request-logger',
];

Document Service middleware​

These intercept operations on the Document Service API (the data layer):

// src/index.js
module.exports = {
register({ strapi }) {
strapi.documents.use(async (context, next) => {
// Only intercept article creates
if (context.uid === 'api::article.article' && context.action === 'create') {
// Auto-generate a slug from the title
if (context.params.data?.title && !context.params.data?.slug) {
context.params.data.slug = context.params.data.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
}

return next();
});
},
};

Policies vs middleware​

AspectPoliciesMiddleware
PurposeAccess control (allow/deny)Request/response transformation
Return valuetrue, false, or throwsCalls next()
Can modify requestTechnically yes, but avoid itYes
Can modify responseTechnically yes, but avoid itYes
ScopeRoute-level onlyGlobal or route-level
Use caseIs-owner, role check, feature flagLogging, caching, rate limiting, CORS

Common pitfalls​

PitfallProblemFix
Forgetting await next() in middlewareRequest hangs, never reaches controllerAlways call await next()
Modifying ctx.body before next()Overwrites the controller responseModify after next() for response manipulation
Policy returning undefinedTreated as allowedExplicitly return false when denying
Global middleware not in config/middlewares.jsMiddleware never loadsAdd it to the array
Expensive logic in middlewareRuns on every matched requestGuard with conditions, use caching

See also​