In this article, we will explore how to build a secure web application using the power of Strapi policies. With Strapi you can build and secure your web application using customizable policies including caching, rate limiting, data validation, authentication and authorization, ip whitelisting and much more. By the end of the article, you will have a solid understanding of how to build a secure web application with Strapi policies.
The primary focus of this article is the API security of a web application.
Policies are sets of rules, guidelines, or predefined actions that govern various aspects of behavior, access, and decision-making within a system, organization, or application.
Policies are functions that execute specific logic on each request before it reaches the controller. They are mostly used for securing business logic.
They are mostly for read-only operations. They are used to perform checks to ensure that the rules or specified guidelines are met.
When creating a Strapi Policy , a true
or false
is returned. The latter is for when the policy is met and the former is when the policy is not met. We could also throw a policy error in place of the false
.
Let us head on to create some policies that will help secure an API.
The project of this article is a location based application. A user can create locations that are interesting and fun to visit so others can see it.
Using this idea, let us implement some policies.
A consent policy helps ensure that individuals have given informed and explicit consent before their data is collected, processed, or used in any way.
In a case where the user hasn’t consented, as shown below, they won’t be able to make requests to the locations
route.
Head over to the policies
folder inside the src
folder and create a file called consent.js
. Add the following code:
1// ./src/policies/consent.js
2const { errors } = require("@strapi/utils");
3const { PolicyError } = errors;
4
5module.exports = async (policyContext, config, { strapi }) => {
6 if (policyContext.state.user.agreeToPolicy) {
7 return true;
8 } else {
9 const user = policyContext.state.user.username;
10 throw new PolicyError(
11 `Hi ${user}, Please agree to terms and conditions to continue.`,
12 {
13 policy: "consent-policy",
14 }
15 );
16 }
17};
In the policy file above, we checked if the user jane_doe
has agreed to the terms and conditions of our application using the property agreeToPolicy
. If true, they are allowed to access a particular route; otherwise, a content policy error is thrown.
Now add this as a global policy to the location
route.
1// ./src/api/location/routes/location.js
2module.exports = createCoreRouter("api::location.location", {
3 config: {
4 find: {
5 middlewares: [
6 ...
7 ],
8 policies: [
9 "global::consent",
10 "global::account-locked",
11 "ip",
12 "rate-limit",
13 ],
14 },
15 create: {
16 policies: ["location-input"],
17 },
18 },
19});
Now, when jane_doe
makes a request to the location
route, she gets this response:
Here, we will implement a policy to check if a user has subscribed to a plan. With this policy we will add a middleware that will handle the number of requests a user can make in a day.
"If you provide an API client that doesn't include rate limiting, you don't really have an API client. You've got an exception generator with a remote timer." – Richard Schneeman
Locate the location
folder in the ./src/api/
folder and create a folder named policies
. Inside this new folder, create a file named rate-limit.js
and add the following code:
1// ./src/api/location/policies/rate-limit.js
2const { errors } = require("@strapi/utils");
3const { PolicyError } = errors;
4
5module.exports = async (policyContext, config, { strapi }) => {
6 if (policyContext.state.user.isSubscribed) {
7 return true;
8 } else {
9 const user = policyContext.state.user.username;
10 throw new PolicyError(`Hi ${user} Please subscribe to view locations`, {
11 policy: "rate-limit",
12 });
13 }
14};
In order for the policy to work, we need to add it to the locations
route middleware that handles the request to get locations. Inside the location
folder, locate the location.js
file inside the routes
folder and add the following code:
1// ./src/api/location/routes/location.js
2"use strict";
3
4/**
5 * location router
6 */
7const { createCoreRouter } = require("@strapi/strapi").factories;
8const { rateLimit } = require("express-rate-limit");
9
10// rate limiting
11const fiveReqLimits = rateLimit({
12 windowMs: 24 * 60 * 60 * 1000, // 1 day,
13 max: 5, // 5 requests,
14 // get ip address of user since it is required
15 keyGenerator: (request, response) => {
16 let ip = strapi.requestContext.get().request.ip;
17 return ip;
18 },
19 handler: async (request, response, next) => {
20 const ctx = strapi.requestContext.get();
21 ctx.status = 403;
22 ctx.body = {
23 message:
24 "You have exhausted your 5 requests for the day. Please check back tomorrow.",
25 policy: "rate-limit",
26 };
27 },
28});
29
30const tenReqLimits = rateLimit({
31 windowMs: 24 * 60 * 60 * 1000, // 1 day,
32 max: 10, // 10 requests,
33 handler: async (request, response, next) => {
34 const ctx = strapi.requestContext.get();
35 ctx.status = 403;
36 ctx.body = {
37 message:
38 "You have exhausted your 10 requests for the day. Please check back tomorrow.",
39 policy: "rate-limit",
40 };
41 },
42});
43
44module.exports = createCoreRouter("api::location.location", {
45 config: {
46 find: {
47 middlewares: [
48 async (ctx, next) => {
49 const ip = ctx.request.ip;
50 if (ctx.state.user.plan === "basic") {
51 fiveReqLimits(ctx.req, ctx.res, (error) => {
52 if (error) {
53 ctx.status = 500;
54 ctx.body = { error: error.message };
55 }
56 });
57 } else {
58 tenReqLimits(ctx.req, ctx.res, (error) => {
59 if (error) {
60 ctx.status = 500;
61 ctx.body = { error: error.message };
62 }
63 });
64 }
65 await next();
66 },
67 ],
68 policies: ["rate-limit"],
69 },
70 },
71});
In the code above, we added a middleware that will handle the find
method of the /locations
route. This means that this middleware handles the GET
HTTP request to the /locations
route. If the user is on the basic
plan, we invoke the rate limit for five requests a day. Else, we invoke the rate limit of ten request per day. And we included the rate-limit
policy in line 68
.
NOTE: The name of our policy file
rate-limit.js
should be the same as the name of our policyrate-limit
that we added.
Using the following data:
As we can see, user1
is on the free plan with the unSubscribed
field as false
. user2
and user3
are both subscribed and on the basic
and standard
plans.
For the basic
plan, a user can only make requests 5 times a day. Unlike the standard plan which is 10 times a day.
When user1
makes request to get random locations, they get a policy error that they need to subscribe.
When user2
makes a request to get random locations, their rate limit decreases by 1.
As we can see, on the first request, their number of requests, X-RateLimit-Limit
, is reduced to 4
, which is the X-RateLimit-Remaining
.
When user3
makes a request to get random locations:
Similar to user2
, user3
will only have 9 requests remaining.
When the maximum request limit has been reached, a policy error is thrown, as shown below:
With a Strapi Policy, we can whitelist or backlist one or more IP addresses. This is to protect and filter out illegitimate or malicious IP addresses from your website. filter out illegitimate or malicious IP addresses.
Locate the policies
folder and create a new policy file called ip.js
. Inside this file, add the code below:
1// ./src/api/location/policies/rate-limit.js
2const { errors } = require("@strapi/utils");
3const { PolicyError } = errors;
4const ip = require("koa-ip");
5
6// ip addresses to blacklist
7const blackList = ["77.88.99.1", "88.77.99.1", "127.0.0.1"];
8
9module.exports = async (policyContext, config, { strapi }) => {
10 const ip = policyContext.request.ip;
11 if (blackList.indexOf(ip) > -1) {
12 const user = policyContext.state.user.username;
13 throw new PolicyError(
14 `Hi ${user} Please this resource is not available to you`,
15 {
16 policy: "ip-blacklist",
17 },
18 );
19 } else {
20 return true;
21
22};
In the code above, we created an array blacklist
that will hold the IP addresses we want to block. Then we checked if the IP of the user, using policyContext.request.ip
, making the request is any of the IPs. If it is in the blacklist, we throw a policy error, else the request will go through.
Now locate inside the location.js
file inside the ./src/api/location/routes/
folder and add the ip
policy to it. Ensure that it is before the rate-limit
policy. This is so that it is first checked before the rate-limit
policy is called.
1policies: ["ip", "rate-limit"]
When a user with any of the blacklisted IPs make a request, they get the following:
Though we could use the beforeCreate
/ beforeUpdate
methods we could also set up policy for input validation.
We will implement a policy that validates inputs from the request body are valid before reaching our controller using joi validation package.
NOTE: Strapi can handle this validations on its own. Yet a policy can be added before Strapi validates the inputs.
Create another policy called location-input.js
inside the policies
folder in the path ./src/api/location/
. Add the following code:
1// ./src/api/location/policies/location-input.js
2const { errors } = require("@strapi/utils");
3const { PolicyError } = errors;
4const Joi = require("joi");
5
6const schema = Joi.object({
7 name: Joi.string().min(3).max(30).required(),
8
9 address: Joi.string().min(5).max(100).required(),
10});
11
12module.exports = async (policyContext, config, { strapi }) => {
13 const { data } = policyContext.request.body;
14 const validationError = schema.validate(data)?.error?.details[0]?.message;
15 if (validationError) {
16 throw new PolicyError(validationError, {
17 policy: "location-creation",
18 });
19 }
20 return true;
21};
In the code above, we created a schema that specifies the validations for the name
and address
fields. These fields are needed to create a new location in our location
model. We validated the body of the request using this schema. If there is a validation error, we throw a policy error with the validation error message.
Next, we add this policy to the create
function of the location
route configuration.
1// ./src/api/location/routes/location.js
2 ...
3 module.exports = createCoreRouter("api::location.location", {
4 config: {
5 find: {
6 ...
7 // policies
8 policies: [],
9 },
10 create: {
11 policies: ["location-input"],
12 },
13 },
14 });
Sending the name
field as a number instead of a string.
This will throw the policy error:
When the input is correct and because we set the name
field to be unique
in Strapi, it will throw an error because we tried to create a new location with a name that already exists.
Here, we will implement the policy to check if a user’s account is blocked until after some time. We will be using a 7 day period of lockout. If a user violates the terms and conditions of the application, their access to resources will be revoked for 7 days.
This policy will be a global policy. For this reason, inside the src
folder, create a new folder policies
. Inside this new folder, create a file called account-locked.js
and add the following code:
1// ./src/policies/account-locked.js
2const { errors } = require("@strapi/utils");
3const { PolicyError } = errors;
4const { DateTime } = require("luxon");
5
6module.exports = async (policyContext, config, { strapi }) => {
7 const dateOfLockout = DateTime.fromISO(
8 policyContext.state.user?.dateOfLockout
9 );
10 const luxonDateTime = DateTime.now();
11
12 const daysDifference = luxonDateTime
13 .diff(dateOfLockout, ["days"])
14 .toObject()?.days;
15
16 // if lockout period has been exceeded
17 if (daysDifference > 7) {
18 return true;
19 } else {
20 let daysRemaining = dateOfLockout.day + 7 - luxonDateTime.day;
21 const user = policyContext.state.user.username;
22
23 throw new PolicyError(
24 `Hi ${user}. Your account has been blocked. Check back in ${daysRemaining.toFixed()} days time`,
25 {
26 policy: "account-locked",
27 }
28 );
29 }
30};
In the code above, we made use of the luxon
NPM package for our date formatter. We got the date when the user was locked out and the current date and time. We checked if the expiration period has elapsed. And made sure that they can now make requests, otherwise we throw the user a Policy error by adding the number of days remaining for the user’s account to be unlocked.
Next, we add this policy to the location
route as a global policy.
1// ./src/api/location/routes/location.js
2...
3module.exports = createCoreRouter("api::location.location", {
4 config: {
5 find: {
6 middlewares: [
7 ...
8 ],
9 policies: ["global::account-locked", "ip", "rate-limit"],
10 },
11 create: {
12 policies: ["location-input"],
13 },
14 },
15});
When we a user’s account is blocked they get the following response as shown below:
In this scenario, we have to create a policy that allows only a user with the field isAdmin
to modify the location
route.
Take a look at the user ted
below whose isAdmin
field is set to false. This means he is not an admin.
Now, locate the policies
folder in the path ./src/api/location
and create a file called is-admin
. Inside this new file, add the code below:
1// path: ./src/api/location/policies/is-admin.js
2const { errors } = require("@strapi/utils");
3const { PolicyError } = errors;
4
5module.exports = async (policyContext, config, { strapi }) => {
6 // check if user is admin
7 if (policyContext.state.user.isAdmin) {
8 // Go to controller's action.
9 return true;
10 }
11 // if not admin block request
12 const user = policyContext.state.user.username;
13 throw new PolicyError(`Hi ${user}, you are not allowed to update this data`, {
14 policy: "admin-policy",
15 });
16};
Locate the location route file and add this policy to the update function controller.
1// ./src/api/location/routes/location.js
2module.exports = createCoreRouter("api::location.location", {
3 config: {
4 find: {
5 middlewares: [
6 ...
7 ],
8 policies: [
9 "global::consent",
10 "global::account-locked",
11 "ip",
12 "rate-limit",
13 ],
14 },
15 create: {
16 policies: ["location-input"],
17 },
18 update: {
19 policies: ["is-admin"],
20 },
21 },
22});
Next, the user ted
, who is not an admin, wants to update the address of the location with id
as 1 below:
Now when ted
makes a request, they get the following:
As we can see above, they are forbidden to make this request. When the user is an admin the request can successfully be executed as shown below:
The complete code for this article can be found here: https://github.com/Theodore-Kelechukwu-Onyejiaku/strapi-policy-usecases
There are lots of ways to ensure API and web security and Strapi Policies help in achieving this. With Strapi Policies, we have been able to demonstrate the use of policy for authorization, rate limiting, IP filtering, input validation, account lockout, and so on to ensure web security of our applications. Join the Strapi Discord community for more.
Theodore is a Technical Writer and a full-stack software developer. He loves writing technical articles, building solutions, and sharing his expertise.