Tuesday, May 9, 2023

LocalStorage vs Cookies: the best-practice guide to storing JWT tokens securely in your front-end

Securely store JSON web tokens in your Node.js web application

In today's web development landscape, JSON Web Tokens (JWT) have become a popular choice for authentication and authorization. However, securely storing JSON web tokens in an application's frontend poses a significant challenge. 

In this article, we will explore various techniques to address this issue and ensure the protection of sensitive user information. 

We will cover the pros and cons of using LocalStorage and cookies and provide code snippets to implement these solutions effectively.

What is a JSON web token (JWT token)?

Before delving into storage options, it's crucial to understand the nature of a JWT token - its an "open standard (RFC 7519) that defines a compact and self-contained way for securely transmitting information between parties as a JSON object."

A JSON web token consists of three parts: a header (think authorization header), a JWT payload, and a signature. 

The payload contains claims, such as user information or permissions (eg. used as an access token), while the signature ensures the token's integrity.

What are the security concerns with storing JSON web tokens in an application frontend?

When it comes to storing JSON web tokens in the frontend, two primary concerns arise:

  1. Protection against XSS attacks (cross-site scripting); and

  2. Mitigation of token theft.

Cross-Site Scripting (XSS) attacks with insecure JWT token storage

XSS attacks occur when an attacker injects malicious code into a website, gaining unauthorized access to sensitive data.

Storing these tokens in insecure locations can make them vulnerable to XSS.

JWT token theft 

If an attacker manages to obtain a user's JWT token, they can impersonate the user and gain unauthorized access to protected resources.

Therefore, it is essential to employ secure storage techniques to prevent token theft.

Storing a JSON web token in Local Storage

LocalStorage is a built-in browser storage mechanism that allows web applications to store data persistently. However, it is crucial to consider its advantages and disadvantages when using it to store a JSON web token.

Pros of LocalStorage

  • Simplicity: The LocalStorage API is straightforward to use, making implementation easier.

  • Persistence: Data stored in LocalStorage remains available even after the user closes the browser or reboots the system.

Cons of LocalStorage

  • XSS attack: storing JSON web tokens in LocalStorage makes them susceptible to a XSS attack.

  • Lack of Encryption: LocalStorage does not provide built-in encryption, encrypted tokens make the stored data virtually inaccessible if an attacker gains access to the user's device.

Storing JSON web tokens in cookies

Cookies are another popular storage mechanism for web applications. You get certain advantages when you use cookies for storing JSON web tokens.

Pros of cookies

  • Built-in Security: Cookies provide a built-in secure flag that allows only encrypted transmission over HTTPS.

  • Same-Origin Policy: Cookies are subject to the same-origin policy, which helps mitigate XSS vulnerabilities.

  • Options for Secure and HttpOnly Flags: By setting the secure flag and HttpOnly flag on cookies, you can enhance their security.

Cons of cookies

  • Complexity: working with cookies can be more complex compared to LocalStorage.

  • Limited Storage: cookies have a size limit of approximately 4KB, which may pose a constraint when storing large JWT tokens.

Best Practices for securely storing JSON web tokens

To securely store a JSON web token in the frontend, consider the following best practices:

  1. Encryption: If you choose to use LocalStorage, encrypt the JWT tokens before storing them to enhance their security. Various encryption libraries and algorithms are available for this purpose.

  2. Short validity: Set a short lifespan for JWT tokens to minimize the window of opportunity for attackers to exploit stolen tokens.

  3. Refresh token: a mechanism that utilizes simple web tokens to refresh tokens and reject tokens that have expired will help to protect your user's data and minimize the chances of data theft.

  4. Secure and HttpOnly flags: If you opt for cookies, utilize the secure and HttpOnly flags to enhance their security. The secure setting in a cookie ensures transmission only over HTTPS, while the HttpOnly flag forbids JavaScript code from accessing the cookie, for example, through the Document.cookie property. 

Preferred Approach: Storing JSON web tokens with cookies for persistent storage

Cookies provide enhanced security, compatibility, session persistence, and scalability, making them the preferred option for persistently storing JSON web tokens in the frontend of a Node.js web application for the  following reasons:

Enhanced security features

Cookies offer built-in security features such as the secure setting for encrypted transmission over HTTPS and the HttpOnly flag to prevent client-side JavaScript code access, minimizing the risk of token theft through XSS attacks.

Here's an example of how to set these flags in a Node.js application using the cookie package:

  
  const cookie = require('cookie');

  // Set a cookie with the secure and HttpOnly flags
  const token = 'your-jwt-token';
  const secureCookie = true;
  const httpOnlyCookie = true;
  const cookieOptions = {
    secure: secureCookie,
    httpOnly: httpOnlyCookie,
  };

  const cookieString = cookie.serialize('jwtToken', token, cookieOptions);

  // Set the cookie in the response header
  res.setHeader('Set-Cookie', cookieString);
  

Same-Origin policy enforcement

Cookies adhere to the same-origin policy, limiting their access to the originating domain. This strengthens protection against a XSS attack and makes it harder for attackers to compromise tokens.

Support for token expiration and revocation

Cookies support setting expiration dates, enforcing token validity periods, and can be easily invalidated on the server side to revoke access if necessary. Here's an example of setting an expiration date for a cookie:

  
  const cookie = require('cookie');

  // Set the expiration date for the cookie (e.g., 7 days from now)
  const expirationDate = new Date();
  expirationDate.setDate(expirationDate.getDate() + 7);

  const cookieOptions = {
    expires: expirationDate,
  };

  const cookieString = cookie.serialize('jwtToken', token, cookieOptions);

  // Set the cookie in the response header
  res.setHeader('Set-Cookie', cookieString);
  

To revoke a cookie, you can set its expiration date to a past date, rendering it invalid.

Compatibility with cross-domain requests

Cookies can be sent with requests to different domains, facilitating authentication and authorization in cross-domain scenarios. This behavior is achieved by configuring the CORS (Cross-Origin Resource Sharing) settings on the server.

Here's an example using the CORS package in a Node.js application:

  
  const cors = require('cors');

  // Enable cross-origin requests and allow credentials (cookies)
  app.use(cors({ credentials: true, origin: 'http://example.com' }));
  

Make sure to replace 'http://example.com' with the appropriate domain or origins that should be allowed to make cross-domain requests.

Persistence across browser sessions

Unlike LocalStorage, cookies persist across browser sessions, ensuring users remain authenticated even after closing and reopening the browser.

Scalability for large tokens

Cookies have a larger storage capacity compared to LocalStorage, making them suitable for storing larger JSON web tokens or additional metadata.

Optimal Secure Solution: Save JWT Tokens in the browser's memory and store the refresh token in a cookie

When it comes to securely storing this type of access token in your web application, an optimal solution is to save the token in browser session storage while storing the refresh token in a cookie protected by the secure and HttpOnly settings.

This approach offers a balance between security and convenience. The JSON web token in session storage provides quick access during the user's session, while the refresh token in a cookie ensures long-term persistence and protection against CSRF attacks.

How does this approach help mitigate a CSRF attack?

Even if a new refresh token is generated by the attacker, they won't be able to read the response if they're using an HTML form.

It's important to understand that preventing attackers from making a succesful fetch or AJAX request in order to read the response requires that your authorization server's CORS policy is set up correctly to prevent requests from unauthorized websites.

Here's a step-by-step guide with Node.js code snippets for each step:

Step 1: Generate and issue tokens

When a user successfully authenticates, generate both a JSON web token and a refresh token on the server-side. The JSON token contains short-lived access information, while the refresh token is a long-lived token used for obtaining new JSON web tokens when they expire.

  
  // Generate JWT token
  const jwt = require('jsonwebtoken');

  const payload = { userId: 'user123' };
  const secretKey = 'your-secret-key';
  const jwtToken = jwt.sign(payload, secretKey, { expiresIn: '1h' });

  // Generate refresh token
  const refreshToken = jwt.sign(payload, secretKey, { expiresIn: '7d' });

  // Return both tokens to the client
  res.json({ jwtToken, refreshToken });
  

Step 2: Save the JSON web token in the browser session

On the client-side, save the JSON web token in browser session storage upon successful authentication. This ensures the token remains available during the user's session but is cleared when the browser tab is closed.

  
  // Save JWT token in session storage
  sessionStorage.setItem('jwtToken', jwtToken);
  

Step 3: Save the refresh token in a secure HttpOnly Cookie

This ensures it is securely stored and inaccessible to client-side JavaScript code. It also helps protect against a CSRF attack.

This step utilises 3 key cookie-specific security controls:

  • The httpOnly flag to prevent JavaScript from reading it.
  • The secure=true flag so it can only be sent over HTTPS.
  • The SameSite=strict flag whenever possible to prevent CSRF. This can only be used if your Authorization Server has the same domain as your front end. If the domains are different, your Authorization Server must set CORS headers in the backend or use other methods to ensure that the refresh token request can only be successfully performed by authorized websites.
  
  // Set refresh token as an HttpOnly cookie
  res.cookie('refreshToken', refreshToken, {
    secure: true, // Set to true if using HTTPS
    httpOnly: true,
    sameSite: 'strict', // Adjust to your requirements
    maxAge: 7 * 24 * 60 * 60 * 1000, // Set the expiration time (7 days in this example)
  });
  

Step 4: How to refresh the JSON web tokens

When the JSON web token expires, the client can use the refresh token stored in the cookie to request a new JSON web token from the server.

This process ensures continuous authentication without requiring the user to manually log in again.

  
  // Refreshing JWT token using the refresh token
  const cookie = req.cookies.refreshToken;

  if (cookie) {
    jwt.verify(cookie, secretKey, (err, decoded) => {
      if (err) {
        // Handle invalid or expired refresh token
        res.status(401).json({ error: 'Invalid or expired refresh token' });
      } else {
        // Generate a new JWT token
        const newJwtToken = jwt.sign({ userId: decoded.userId }, secretKey, { expiresIn: '1h' });

        // Update the JWT token in session storage
        sessionStorage.setItem('jwtToken', newJwtToken);

        // Return the new JWT token to the client
        res.json({ jwtToken: newJwtToken });
      }
    });
  } else {
    // No refresh token found, prompt user to log in
    res.status(401).json({ error: 'Refresh token not found' });
  }
  

How to test security of tokens and cookies?

There are very few tools that allow software developers to protect their web applications against hackers - without the help of specialist application security experts.

However, Cyber Chief is one such tool that allows you to run regular vulnerability scans with a web app vulnerability scanning tool.

Start your free trial of Cyber Chief now to see not only how it can help to keep attackers out, but also to see how you can ensure that you ship every release with zero known vulnerabilities. 

Or, if you prefer to have an expert-vetted vulnerability assessment performed on your Node.js application you can order the vulnerability assessment from here. Each vulnerability assessment report comes with:

  • Results from scanning your application for the presence of OWASP Top 10 + SANS CWE 25 + thousands of other vulnerabilities.
  • A detailed description of the vulnerabilities found.
  • A risk level for each vulnerability, so you know which GraphQL endpoints to fix first.
  • Best-practice fixes for each vulnerability, including code snippets where relevant.
  • One-month free access to our Cyber Chief API & web application security testing tool.
  • Email support from our application security experts.

Which option do you prefer?