Use of JWT in httpOnly cookie #4632

For all those struggling with this cookie auth: I have found a solution for my case.

So my config like something like this:
My Client: https:// client.lorem.com
Strapi: https://strapi.lorem.com

Until now I cannot achieve the very last task: make the browser to save the cookie. The cookie itself was correctly set by the server and attached to the response header but for some reason the browser didn’t want to save the cookie, which would be then visible under application/cookies in Chrome.

The solution was quite simple: the key is to set the right URL for cookie’s domain in Auth.js.

I was convinced that in my case it should be my client’s URL. Then I removed https:// but still it didn’t work. I don’t know why but after some time and a few re-deployments of Strapi I could see a yellow icon with a following warning: set-cookie domain attribute was invalid with regards to the current host url next to response set-cookie. It turned out that domain must be set either to lorem.com or strapi.lorem.com. Finally with so adjusted domain the cookie will be saved in the browser under both client and api URL and the whole authentication process will working.

1 Like

Can you clarify which part you are referring to @kwiat1990 ? (Also thank you for the information :wink: )

Yeah, I made changes in Auth.js, where the cookie with user’s token is created (I adjusted domain’s URL and added sameSite: "none") and I needed also to declare in Strapi’s middleware.js cors - only cors option. Important was to add both URLs - client and api.

1 Like

Hi @kwiat1990, can you perhaps share your final working code?
I followed the instructions from https://talke.dev/strapi-user-permissions-jwt-cookies and although I can see the cookie in the browser in the response header, it is not saved by the browser in application/cookies.

My browser isn’t saving the cookie, although Strapi does add it to the response header.


Following the instructions from talke.dev I did the following:

// .extensions/users-permissions/controllers/Auth.js

... omitted code ...

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({
          jwt: strapi.plugins["users-permissions"].services.jwt.issue({
            id: user.id,
          }),
          status: "Authenticated",
          user: sanitizeEntity(user.toJSON ? user.toJSON() : user, {
            model: strapi.query("user", "users-permissions").model,
          }),
        });

... omitted code ...
// .extensions/users-permissions/config/policies/permissions.js
... omitted code ...

let role;

  if (ctx.state.user) {
    // request is already authenticated in a different way
    return next();
  }

  if (ctx.request && ctx.request.header && !ctx.request.header.authorization) {
    const token = ctx.cookies.get("token");
    if (token) {
      ctx.request.header.authorization = "Bearer " + token;
    }
  }

... omitted code ...

and finally

// .config/middleware.js

module.exports = () => ({
  settings: {
    cors: {
      enabled: true,
      credentials: true,
      origin: ["http://localhost:3001"],
      headers: [
        "Content-Type",
        "Authorization",
        "X-Frame-Options",
        "access-control-allow-origin",
      ],
    },
  },
});

On the client-side, I am using a brand new React app with a Login component:

// Login.js - on React Client

const handleSubmit = async (e) => {
    e.preventDefault();
    try {
      const response = await fetch("http://localhost:1337/auth/local", {
        method: "POST",
        credentials: "same-origin",
        // mode: "no-cors",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify(data),
      });
      console.log(response);
    } catch (error) {
      console.log(error);
    }
  };

PS, if I test using Postman as a client, it works just fine.

What am I doing wrong?

@Stephan_Du_Toit yeah, sure. When it comes to the implementation, then I have done everything as described in the tutorial. The only difference is that I went with cookieSetter and cookieGetter as part of Strapi’s middleware because I think it’s the most elegant solution.

The most crucial thing is to set origin URLs correctly. You have to declare the full path (e.g. “https://my-strapi.com” and “https://my-frontend.com”) to your backend and your frontend.

This is my middleware.js file:

module.exports = ({ env }) => ({
  load: {
    before: [
      "cookieGetter",
      "responseTime",
      "logger",
      "cors",
      "responses",
      "gzip",
    ],
    order: [
      "Define the middlewares' load order by putting their name in this array is the right order",
    ],
    after: ["parser", "router", "cookieSetter"],
  },
  settings: {
    cors: {
      origin: [
        env("CLIENT_URL", "http://localhost:8080"),
        env("API_URL", "http://localhost:1337"),
      ],
    },
    cookieGetter: {
      enabled: true,
    },
    cookieSetter: {
      enabled: true,
    },
  },
});

Implementation details of both cookie functions are below. IMPORTANT: inside cookie setter you need to declare a domain property (process.env.CLIENT_HOSTNAME), which value is the hostname of your frontend, e.g. “my-frontend.com”.

// cookieSetter
module.exports = (strapi) => {
  return {
    initialize() {
      strapi.app.use(async (ctx, next) => {
        await next();
        if (
          ctx.request.url.startsWith("/auth/") &&
          ctx.response.status === 200
        ) {
          const { jwt: jwtToken } = ctx.response.body;
          ctx.cookies.set("token", jwtToken, {
            httpOnly: true,
            secure: process.env.NODE_ENV === "production",
            maxAge: 1000 * 60 * 60 * 24 * 14, // 14 Day Age
            domain: process.env.CLIENT_HOSTNAME,
            sameSite: process.env.NODE_ENV === "development" ? true : "none",
            overwrite: true,
          });
        }
      });
    },
  };
};

// cookieGetter
module.exports = (strapi) => {
  return {
    initialize() {
      strapi.app.use(async (ctx, next) => {
        if (
          ctx.request &&
          ctx.request.header &&
          !ctx.request.header.authorization
        ) {
          const token = ctx.cookies.get("token");
          if (token) {
            ctx.request.header.authorization = `Bearer ${token}`;
          }
        }
        await next();
      });
    },
  };
};

With this code I have a fully functional authorization process, which works like a charm.

3 Likes

Awesome! Thanks so much. I will try it shortly and revert with the result.

@kwiat1990 Thanks so much! It worked. I adapted my code to use the middleware approach. Smooth sailing!

I’m happy that I could help. I have struggled with this one as well and spent way too much time investigating what was wrong.

I think the JWT httpOnly cookie would guarantee sufficient security in combination with CSRF tokens. Does anyone know how to implement these together?

Agree, with one mention: it depends how csrf is implemented! You have to keep in mind that Strapi token is valid for a month! When you use httpOnly cookie without secure flag the cookie can be captured since it will remain saved in some logs. See more about httpOnly cookie - secure flag.

Regarding implementation, I think the best way is to do this on downstream, not in Strapi. This is how I did it in my Strapi Access Proxy project where I used csurf and jsonwebtoken libraries, you may want to study it to see how to implement a solution in any way you want.

Would you mind sharing the code where you implement CSRF tokens in Strapi / KOA? Im struggling with implementing this!

I didn’t author the original posts, only migrated the discussion from Github.

Session vs token-based authentication - interesting discussion! They each have their trade-offs as mentioned already. If using JWT for authentication, I think they should be limited to a short life span, which depends on the risk tolerance of the particular business application (on average 20 mins). It would be great if we have control over the expiry in Strapi. (Can we already do this? I haven’t looked into it.)

Also, my opinion is that Authentication tokens (JWT’s or otherwise) shouldn’t be stored in local or session-storage especially if they have long lifespans, due to XSS vulnerabilities.

Re: Ways of storing a JWT in a cookie and mitigating CSRF attacks: I’ve used this OWASP cheatsheet as a guide in previous projects and use the double-submit method. I believe Django uses this too.
https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html

2 Likes

Totally agree, but the access can be managed in downstream, I think this is the best solution not just for Strapi, but for all apps managing sensitive information. Check my project strapi access proxy to see the concept

1 Like

Hi! Hello & thanks for your feedback.

While I do agree that modifying the original Strapi code is not ideal (and will require really keeping an eye on updates for such a key feature) my concern is the cookieSetter/cookieGetter strategy performance (maybe early optimization here?) since those middlewares will run for every single request, which seems unnecessary for a piece of code that needs to be run only at one specific almost-single-use functionality :confused:

Do you have any feedback on performance?

Best! :raised_hands:

Hi everyone.

I’ve been thinking of a new way to apply to solve this in a way that doesn’t require overwriting the core auth controller (auth/local) nor running the cookieSetter middleware on every request/response.

I have come up with the idea of creating a custom POST endpoint (route/controller) that takes the user credentials and use them to perform a new request to Strapi’s core login endpoint: auth/local, capturing the response and if successful (jwt is included) adding the jwt value in a secure cookie.

Example:

Custom controller directories:
/api/custom/
/api/custom/config/
/api/custom/controllers/

Route: /api/custom/config/routes.js
:star2: Now use this route to POST your user credentials (instead of Strapi’s official /auth/local route).

{
  "routes": [
    {
      "method": "POST",
      "path": "/auth/cookielogin",
      "handler": "Custom.index"
    }
  ]
}

Controller: /api/custom/controllers/Custom.js

const axios = require("axios");

module.exports = {

  // GET /auth/cookielogin
  async index(ctx) {

    // Capture the request body (identifier and password)
    const { body } = ctx.request;

    // Build Strapi's Absolute Server URL.
    // Copied from https://github.com/strapi/strapi/blob/86e0cf0f55d58e714a67cf4daee2e59e39974dd9/packages/strapi-utils/lib/config.js#L62
    const hostname =
      strapi.config.environment === "development" &&
      ["127.0.0.1", "0.0.0.0"].includes(strapi.config.server.host)
        ? "localhost"
        : strapi.config.server.host;
    const absoluteURL = `http://${hostname}:${strapi.config.server.port}`;

    try {
      // Now submit the credentials to Strapi's default login endpoint
      const { data } = await axios.post(`${absoluteURL}/auth/local`, body);

      // Set the secure cookie
      if (data && data.jwt) {
        ctx.cookies.set("jwt", data.jwt, {
          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,
        });
      }

      // Respond with the jwt + user data, but now this response also sets the JWT as a secure cookie
      return ctx.send(data);

    } catch (error) {

      return ctx.badRequest(null, error);

    }
  },
};

Now you can use the cookieGetter middleware (detailed above by other users) to make sure your JWT is taken from the cookie and passed into the requests authorization headers, where Strapi is expecting them to be.

Thanks to everyone for the discussion and collaboration. I hope this is useful for some of you.

*Based on what I’ve seen so far, I’m quite excited about what Strapi is doing :clap:

Hi author, What is file in order to add logic cookieGetter, cookieSetter? and cokies could working with provider google or facebook?

1 Like

Okay so everyone wants to move the JWT into a cookie. Fine, but then everyone now must add some CSRF protection to ensure naughty guys from out there won’t ride your authenticated session to do some nasty POSTs on your behalf.
Most common way is to use CSRF tokens. Backend (strapi) gives a frontend a CSRF token and the frontend sends it back on requests.
For those who only use AJAX to talk to strapi backend, the protection is simplified to just setting a custom header on every request to strapi. And on the strapi side, checking that the header is there (rejecting the requests that are lacking the header).

This is all easily implemented by having a custom middleware on Strapi side and by updating a frontend.
But as soon as you add CSRF checks to strapi backend, it will break GraphQL playground and Admin UI (these guys do not use CSRF tokens and they don’t need to).

So, how does one add CSRF protection to strapi so their frontend is secured but does not break the Admin UI?
Good Ideas anyone? Perhaps I missed something?

Hey,

I did it according to How to put JWT's in server side cookies using the Strapi user-permissions plugin | Christopher Talke | Coffs Harbour based ICT Professional | talke.dev but my front-end application cannot see the cookie. I use js-cookie (Cookies.get) but after these changes I have “undefined” in console.log(user). Of course, I deleted Cookies.set() and I added { withCredentials: true, } (I use axios and Next.js).

How can this be rectified? Because I don’t know :frowning:
Please help me.