How to put JWT's in server side cookies using the Strapi user-permissions plugin
Christopher Talke Buscaino
This post won't explain what Strapi is, how it works, or even when you should use it. If you are after that type of information, just see any of the below links...
This post will instead explain how to modify the included plugin strapi-plugin-users-permissions
in order to force it to use server side cookies and give you the ability to set http only
or secure
options. This will ensure that your frontend application does not have to store sensitive data such as JWT's in client side local, session or cookie storage for future authenticated requests.
🔧 Setup
Before you start you'll need to import some files into your project from the Strapi master
branch that relate to the strapi-plugin-users-permissions
plugin, see this link for the specific package: Strapi Plugin User Permissions source.
- Copy the code from ../controllers/Auth.js to this location in your project
/extensions/user-permissions/controllers/Auth.js
. - Copy the code from ../config/policies/permissions.js to this location your project
/extensions/user-permissions/config/policies/permissions.js
.
Once you've completed this your folder directory should look a little something like this:
If you want to understand why we are doing this, please reference Strapi's documentation: Customization. Essentially we are setting ourselves up to override the methods used by the strapi-plugin-users-permissions
plugin.
🥇 Modify 'Auth' Handler
First we need to ensure that when you hit the normal auth/local
endpoint, that the JSON Web Token (JWT) is issued via a cookie rather then be included in the body of the http response.
To do this you will need to modify the Auth.js
file we imported earlier, first you'll need to find this code within the callback()
method, and change it from this:
ctx.send({
jwt: strapi.plugins['users-permissions'].services.jwt.issue({
id: user.id,
}),
user: sanitizeEntity(user.toJSON ? user.toJSON() : user, {
model: strapi.query('user', 'users-permissions').model,
}),
});
To this:
const token = strapi.plugins["users-permissions"].services.jwt.issue({
id: user.id,
});
ctx.cookies.set("token", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production" ? true : false,
maxAge: 1000 * 60 * 60 * 24 * 14, // 14 Day Age
domain: process.env.NODE_ENV === "development" ? "localhost" : process.env.PRODUCTION_URL,
});
ctx.send({
status: 'Authenticated',
user: sanitizeEntity(user.toJSON ? user.toJSON() : user, {
model: strapi.query('user', 'users-permissions').model,
}),
});
The above change will do the following:
- Generate the JSON Web Token and assign it to the variable
token
- Use a Koa.js method to create a cookie, using the
token
variable as the value, and allow us to pass our custom cookie options - Allows you to use environment variables such as
NODE_ENV
andPRODUCTION_URL
to dynamically set cookie values depending on the environment. - Modify the http response to replace the
jwt
key with astatus
key to communicate a successful authentication
Great - with that change you can test your auth/local
route with a tool like Postman, if everything went right you should now see a token
cookie with your JSON Web Token as the value!
Now you just need to be able to make authenticated requests to Strapi, if you try to make a request to a protected route right now, even with a cookie, it will not work. In order to fix this we need to modify the other file we imported earlier to include the cookie in the authentication business logic.
🥈 Modify 'Permissions' Config Policy
Open the permissions.js
file we imported earlier, and add the following code the top of the function in this file.
Your code will go from looking like this:
let role;
if (ctx.request && ctx.request.header && ctx.request.header.authorization) {
// Rest of the code
}
To this:
let role;
if (ctx.request && ctx.request.header && !ctx.request.header.authorization) {
const token = ctx.cookies.get("token");
if (token) {
ctx.request.header.authorization = "Bearer " + token;
}
}
if (ctx.request && ctx.request.header && ctx.request.header.authorization) {
// Rest of the code
}
The above change will do the following:
- Checks if the
ctx.request
andctx.request.header
are notundefined
values, and then checks thatctx.request.header.authorization
isundefined
- If all of the above comes back as
true
, we then pull the token from the cookie using thectx.cookies.get()
method and assign it to thetoken
variable. - If the
token
is notundefined
, we modifyctx.request.header.authorization
to act as aBearer Token
header to let the rest of the business logic know that we are an authenticated user.
Now you'll be able to authenticate
and continue to make requests to protected api routes using your new secure httpOnly cookie! Now the only problem you have is that you can't logout, or kill the cookie... yet.
🥉 Custom 'Logout' Router and Handler
Before we start on this section, I just want to say that there may be better ways to do this, however, this is how I solved this problem.
First go to your api
folder in your project, and create a folder called custom
and inside of this directory create the following folders config
and controllers
.
From there we will need to create the routes so that Strapi accepts traffic to our custom route, you can do this by creating a routes.json
file in the config
folder and insert the following code:
{
"routes": [
{
"method": "POST",
"path": "/logout",
"handler": "Custom.logout"
}
]
}
Okay perfect - the above .json
file will let Strapi know the following:
/logout
is a valid path for aPOST
http request- If a request comes through, look for a
logout()
function in thecontrollers/Custom.js
file.
To make sure that something actually happens when we hit that route, lets create that file controllers/Custom.js
, and insert the following code:
"use strict";
module.exports = {
async logout(ctx) {
ctx.cookies.set("token", null);
ctx.send({
authorized: true,
message: "Successfully destroyed session",
});
},
};
With that you'll now have a completely functional authentication process in Strapi, and you won't need to manually store your JWT in client side storage when you are making requests from your frontend!
💜 Credit
While doing research on how to achieve this, I came across this Github Issue. Within this issue, you will find a reply from a user called sanojsilva
where I pulled the idea / logic from, however, there was no clear documentation on how this is achieved. Thanks for the pointer sanojsilva
and I hope this helps someone who is trying to figure this out.
🤔 Frequently Asked Questions
Since I had originally published this blog post it has picked up some traction, either due to the link I left in a Github Issue, or due to someone I look up (with a big following, aka Wes Boss) taking notice of it and sharing it.
Either way, a few people have asked me repeat questions, so I thought I'd add them here for others that are trying to resolve this issue.
=== Question 1 ===
When I authenticate in the browser the cookies arn't being stores in the browser, however, I can see them in tools such as Postman, and I can even see the cookie in the response! Chris what the hell is happening?!
Okay relax, relax, the same thing happened to me after I posted this 😑*. After some debugging, and frantic searching, I realised that your frontend request NEEDS to include the 'withCredentials' header.*
In my use case I had a React JS frontend, with axios as my http library. Please see the below code for reference:
const login = async () => { await axios .post( `${constants.API}/auth/local`, { identifier: username, password, }, { withCredentials: true, // <-- This is a critical part of the HTTP Request, if you are not using axios, please refer to the docs of your http client for a 'withCredentials' header } ) .then(() => { ... }) .catch((e) => { ... }); };
>**=== Question 2 ===<br/>Does this work with the GraphQL Plugin?**
>A fella by the name of [eikaramba](https://github.com/eikaramba) raised Issue - [Use server side cookies with graphql #3](https://github.com/christopher-talke/talke-dev/issues/3) which highlights how this can be done.
>Thank you eikaramba 👍🏽
>Anyway, if any more questions come through in the future, I'll keep adding them to this post!