Save JWT To HttpOnly Cookie Instead of LocalStorage
Adam C. |

NextFeathers uses JSON web token (JWT) for authentication when calling the Restful API implemented by FeathersJS.   The JWT token was simply saved in the browser's localStorage and removed when the user is logged out. Many people said this is very bad because the hacker could run Javascript via what so-called XSS on your website, and read the data from localStorage. Personally, I kinda against this because it's unlikely happened, and as I know that is how AWS-amplify works by default. But there is indeed a risk, so I would like to fix it.

Photo by David Fartek on Unsplash

Saving JWT to HttpOnly Cookie sounds like a solution, but there is an issue that I have  “Next' and ”Feathers" on different domains (i.e. different ports,) and the cookie can only be sent with requests made to the same domain inside a Cookie HTTP header. That means, we set the httpOnly cookie on deniapps.com, which cannot be sent to api.deniapps.com, so we are not able to pass the JWT token for authentication. To fix this, we have to use a middleware called Proxy. So the API request will be sent to the same domain, for example, https://deniapps.com/api/proxy/post, and then the Proxy will pass the request to the real API at https://api.deniapps.com/post. 

The overall workflow is like below:

  1. User logs in at https://deniapps.com/api/proxy/authentication.
  2. The proxy transfers the request URL to the real API endpoint, for example, http://localhost:3030/authentication 
  3. Before the proxy sends the response back to the client, it saves the accessToken to the httpOnly cookie and sends only the user's info (like username, email, etc.) back to the client.
  4. The client saves the user's info into the user context used by other page components.
  5. When the client makes any private API call, it calls the proxy endpoint, like https://deniapps.com/api/proxy/posts.
  6. The proxy reads the cookie for JWT, which is set to the “Authorization” header before calling the real URL endpoint.
  7. When the client receives the response passed by the proxy, it checks the HTTP status. If it's 401, then log out the user, otherwise, use response data to render the page.

The main part is pages/proxy/[…path].js, which I modified from the one in Max's post. (Thank you! Max)

import httpProxy from "http-proxy";
import Cookies from "cookies";
import url from "url";

const API_URL = process.env.API_HOST;

const proxy = httpProxy.createProxyServer();

export const config = {
  api: {
    bodyParser: false,
  },
};

export default (req, res) => {
  return new Promise((resolve, reject) => {
    const pathname = url.parse(req.url).pathname;
    const isLogin = pathname === "/api/proxy/authentication";

    const cookies = new Cookies(req, res);
    const authToken = cookies.get("auth-token");

    // Rewrite URL, strip out leading '/api'
    // '/api/proxy/*' becomes '${API_URL}/*'
    req.url = req.url.replace(/^\/api\/proxy/, "");

    // Don't forward cookies to API
    req.headers.cookie = "";

    // Set auth-token header from cookie
    if (authToken) {
      req.headers["Authorization"] = authToken;
    }

    proxy
      .once("proxyRes", (proxyRes, req, res) => {
        if (isLogin) {
          let responseBody = "";
          proxyRes.on("data", (chunk) => {
            responseBody += chunk;
          });

          proxyRes.on("end", () => {
            try {
              const { accessToken, user } = JSON.parse(responseBody);
              const cookies = new Cookies(req, res);
              cookies.set("auth-token", accessToken, {
                httpOnly: true,
                sameSite: "lax", // CSRF protection
              });

              res.status(200).json({ user });
              resolve();
            } catch (err) {
              reject(err);
            }
          });
        } else {
          resolve();
        }
      })
      .once("error", reject)
      .web(req, res, {
        target: API_URL,
        autoRewrite: false,
        selfHandleResponse: isLogin,
      });
  });
};

I also created a local API to check if the user is logged in named “check-login.js” as below:

/**
 * pages/api/check-login.js
 *
 * A API endpoint for checking if the user is logged in.
 */
import { isExpired } from "helpers/common";

export default (req, res) => {
  const Cookies = require("cookies");
  const cookies = new Cookies(req, res);
  const authToken = cookies.get("auth-token") || "";

  const loggedIn = !isExpired(authToken);

  res.status(200).json({
    loggedIn,
  });
};

I checked user is logged in in the _app.js when the componentDidMount, so basically every time a page is hard loaded, it will check if the user is logged in, if so, user info is shown on the header, otherwise, the “Login” button is shown instead.

/**
 * pages/_app.js
 */  
  componentDidMount = async () => {
    const deniUser = localStorage.getItem(NEXT_PUBLIC_USER_LC_KEY);

    if (deniUser) {
      const userStatus = await axios
        .get("/api/check-login")
        .then((response) => response.data);
      if (userStatus.loggedIn) {
        const deniUserObj = JSON.parse(deniUser);
        this.setState({
          user: deniUserObj.firstName,
        });
      }
    }
    this.setState({
      isReady: true,
    });

  };

Note

Initially, I used https://api.deniapps.com in the production, but go the following error:

SyntaxError: Unexpected token in JSON at position 0

I think it's related to HTTP. To use HTTPS as a proxy target, we need to use “SSL” options with key/cert defined. But since, I have an API server running on the same server with a different port, so I could just change it to http://localhost:5050. 

Also, here I don't set cookie expiration, so it expires at the end of the session (the could mean a different thing for different browsers.) 

The last thing, you may take a look at how I implement the log out at  NextFeathers. And check out how to renew the access token if needed.