In this article
March 4, 2025
March 4, 2025

JWT storage 101: How to keep your tokens secure

Want to keep your JWTs safe from attackers? This guide covers the best practices for securely storing your tokens and ensuring your app's security.

In the world of web development and authentication, JSON Web Tokens (JWTs) have become one of the most popular methods for securely transmitting information between parties. These tokens are compact, URL-safe, and allow for efficient, stateless, secure communication between users and servers, making them a staple in modern app development.

However, like any powerful tool, JWTs come with their own set of security challenges—especially when it comes to storing them properly. If not handled correctly, these tokens can become a prime target for attackers, potentially exposing your users' sensitive data.

In this article, we’ll explore the best practices for storing JWTs securely, helping you protect your app from common vulnerabilities, and ensuring that your tokens stay out of the wrong hands. We will focus on how to store JWT securely on the client since this is where we have most problems due to the fact that browsers and mobile apps cannot safely hold secrets.

Let’s dive into securing your JWTs!

What is a JWT?

A JWT is a compact, URL-safe token that contains a set of claims. It typically consists of three parts:

  1. Header: Contains metadata about the token, like its type and the signing algorithm.
  2. Payload: Contains the claims or the actual data, such as user ID or roles.
  3. Signature: The signature ensures the integrity of the token. It’s created by signing the header and payload using a secret or a private key.

JWTs are primarily used for authentication and authorization purposes. When a user logs in, the server generates and signs a JWT containing user information. The token is sent to the client, which can then include it in requests to the server to authenticate future requests.

Since JWTs often contain sensitive data, ensuring their security is paramount. If an attacker gains access to a JWT, they may be able to impersonate a user or gain unauthorized access to a system. The security of JWTs relies heavily on how and where they are stored on the client side. Improper storage can expose these tokens to theft and misuse.

Storing JWT in HTTP cookies (recommended)

Cookies are small pieces of data stored by the browser and sent with every request to the domain they belong to. They can be configured with additional security flags to help protect them:

  • The HttpOnly flag makes cookies inaccessible to JavaScript, reducing the risk of XSS attacks.
  • The Secure flag ensures that cookies are only sent over HTTPS.
  • The SameSite flag is a security feature used in web browsers to control when cookies are sent along with cross-site requests. It helps protect against cross-site request forgery (CSRF) attacks, ensuring that cookies are only sent in a secure context, such as same-site requests. The SameSite attribute can have three possible values:
    • SameSite=Strict: This means the cookie will only be sent if the request is from the same origin (domain) as the site setting the cookie. It won't be sent for cross-site requests, even if the user is navigating from one site to another. This provides the highest level of security.
    • SameSite=Lax: This is a more lenient setting. The cookie will be sent with same-site requests, and it will also be sent with cross-site requests only for top-level navigation (such as clicking a link). However, it will not be sent with embedded content (like images or frames) from another site. This strikes a balance between security and usability.
    • SameSite=None: This allows the cookie to be sent with both same-site and cross-site requests, meaning it can be used for things like third-party services (e.g., embedded ads, cross-site authentication). However, when using SameSite=None, the cookie must also have the Secure flag set, meaning it can only be sent over HTTPS connections.
  • The MaxAge or Expires attribute ensures the JWT has a limited lifespan, requiring re-authentication after a certain period.

When the user logs in and the server issues a JWT, the token is set as an HTTP cookie:

  
document.cookie = `access_token=${jwtToken}; Secure; HttpOnly; SameSite=Strict; Path=/; Max-Age=3600`;
  

Some things to keep in mind when you store JWT in cookies are:

  • Cookies have a size limit (usually 4 KB), so ensure the token is small enough to fit within this limit.
  • Use short expiration times for the token and refresh it when necessary to balance usability and security.
  • If not configured properly, cookies are vulnerable to cross-site request forgery (CSRF) attacks. However, this risk can be mitigated with the use of the SameSite cookie attribute.

Storing JWT in local storage

Local storage is a key-value store that allows data to persist even after the browser is closed. It is easy to use and accessible across page reloads.

When the user logs in and the server provides a JWT, store it in the browser's local storage:

	
localStorage.setItem('access_token', jwtToken);
	

The token can be retrieved later when making API requests and then added manually to each API request in the Authorization header:

	
const token = localStorage.getItem('access_token');
	

localStorage allows you to store a larger amount of data (usually around 5-10 MB, depending on the browser), compared to cookies (typically around 4 KB). This can be useful if your JWT contains many claims or other data.

While localStorage provides ease of access, it also presents significant security risks:

  • localStorage is vulnerable to cross-site scripting (XSS) attacks. If an attacker can inject malicious JavaScript into your web page (through a vulnerability such as unsanitized user input), they could retrieve the JWT from local storage and use it to impersonate the user.
  • JWTs often have an exp (expiration) claim, but local storage does not support automatic expiration of items. The token will remain in the storage until explicitly removed by the application or the user, potentially leaving old or invalid tokens in storage. This creates a scenario where an attacker who gains access to the token can use it even after it should have expired, unless proper expiration checks are implemented in the application.
  • localStorage is not related to network traffic, so it doesn't have a direct protection mechanism like cookies with the Secure flag (which ensures data is only transmitted over HTTPS). While localStorage itself isn’t sent in network requests, if the application isn't protected by HTTPS, an attacker could still intercept the network traffic and steal the JWT if it is sent in request headers.
  • Unlike cookies with SameSite attributes, localStorage does not have any built-in protections against cross-site request forgery (CSRF) attacks. You must manually handle security and ensure that your API endpoints are protected.

Some mitigation strategies include:

  • To prevent XSS attacks:
    • Always sanitize and escape any user input to prevent malicious scripts from being injected into your web pages.
    • Implement a strong Content Security Policy (CSP) header to restrict the types of scripts that can be executed on your site. For example, you can disallow inline JavaScript execution and only allow trusted script sources. For example, Content-Security-Policy: default-src 'self'; script-src 'self' https://trusted.com; object-src 'none';
    • Always sanitize data before injecting it into the DOM (for example, using libraries like DOMPurify to sanitize HTML content).
  • To handle token expiration and rotation:
    • Check the token's exp claim on every request to ensure the token is still valid and hasn't expired.
    • You can implement automatic token refresh logic using refresh tokens. The refresh token can be securely stored in an HttpOnly cookie, and it can be used to obtain a new access token when the current one expires. This prevents you from keeping long-lived JWTs in localStorage and provides an additional layer of security.
    • After each use, issue a new refresh token and invalidate the old one. This reduces the risk of a stolen refresh token being used to generate new access tokens.
    • When the JWT expires, you can either remove the token from localStorage and log the user out or automatically refresh the token by requesting a new one from your server.
  • To secure network traffic:
    • Always serve your application over HTTPS to encrypt the communication channel and protect the token from MITM (man-in-the-middle) attacks.
    • Ensure that your JWT is sent in the Authorization header over a secure connection to avoid interception.

Storing JWT in session storage

Session storage is similar to local storage but only persists for the duration of the page session. Once the browser or tab is closed, the data is cleared.

When the user logs in and the server provides a JWT, store it in the browser's local storage:

	
sessionStorage.setItem('access_token', jwtToken);
	

The token can be retrieved later when making API requests and then added manually to each API request in the Authorization header:

	
const token = sessionStorage.getItem('access_token');
	

Like local storage, session storage is vulnerable to cross-site scripting (XSS) attacks. The difference is that it is cleared automatically, which adds an extra layer of protection by reducing the window of opportunity for a token to be stolen. However, this doesn't eliminate the security concerns around XSS.

While sessionStorage provides a session-based storage solution, there are still security risks, particularly related to cross-site scripting (XSS) attacks, similar to localStorage :

  • Vulnerability to XSS attacks.
  • No built-in token expiry handling.
  • No protection against Man-in-the-Middle (MITM) attacks.
  • No protection against cross-site request forgery (CSRF) attacks.

For details on these risks, and mitigation steps if you choose to use it, see the previous paragraph on localStorage.

Storing JWT in-memory

In-memory storage involves storing the JWT in the browser's memory, typically within JavaScript variables.

This is considered one of the most secure options because the JWT is not stored in persistent storage (like local or session storage), meaning it is less likely to be stolen by an attacker.

The main downside is that the token is lost when the user refreshes the page or navigates to a new page. It can also lead to poor user experience:

  • In-memory storage is wiped out when the page is refreshed or the browser tab is closed. This means the user must authenticate again after refreshing or closing the page.
  • If a user opens the application in multiple tabs, each tab will have its own in-memory state for the JWT, which could lead to discrepancies in the user's authentication state between tabs. One approach to allow tab sharing is to store session-related state in Session Storage or IndexedDB, but these still present security trade-offs (e.g., vulnerability to XSS).

Here's a basic example of how you can store and use a JWT in memory for making authenticated requests:

	
let jwtToken = null;

// Function to store JWT in memory
function storeJWT(token) {
  jwtToken = token;
}

// Function to get JWT from memory
function getJWT() {
  return jwtToken;
}

// Example of how to include the JWT in an HTTP request
function makeAuthenticatedRequest(url) {
  const token = getJWT();
  if (!token) {
    console.error('No JWT found');
    return;
  }

  fetch(url, {
    method: 'GET',
    headers: {
      'Authorization': `Bearer ${token}`,
    },
  })
    .then(response => response.json())
    .then(data => {
      console.log('Data:', data);
    })
    .catch(error => {
      console.error('Error:', error);
    });
}

// Example of storing the JWT and making an authenticated request
storeJWT('your_jwt_token_here');
makeAuthenticatedRequest('https://api.example.com/secure-data');
	

In-memory storage is typically used for short-lived sessions. For longer sessions (e.g., hours or days), a refresh token mechanism would be necessary to provide a seamless experience, but that requires careful management of refresh tokens (preferably in HttpOnly cookies).

If you are storing JWT in memory, make sure that the JavaScript environment is secure. If your site is vulnerable to XSS attacks, attackers may still be able to execute malicious scripts that could steal the JWT from memory.

Storing JWT in mobile apps

SharedPreferences (for Android) or NSUserDefaults (for iOS) are generally not encrypted and are easily accessible by malicious apps or through rooting/jailbreaking. Do not store sensitive tokens in these places. Mobile operating systems provide hardware-backed credential storage inaccessible to app processes. Use them instead.

Storing JWT in Android

In Android, you should first create an encryption key that will be used to encrypt and decrypt the JWT. This can be done using the Android Keystore system.

  
import android.security.keystore.KeyGenParameterSpec;
import android.security.keystore.KeyProperties;
import java.security.Key;
import java.security.KeyStore;
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import java.util.Base64;

public class KeystoreUtils {

    private static final String KEY_ALIAS = "my_secure_key_alias";
    private static final String KEYSTORE_PROVIDER = "AndroidKeyStore";

    public static SecretKey createKey() throws Exception {
        // Set up the KeyGenParameterSpec for Keystore
        KeyGenParameterSpec keyGenParameterSpec = new KeyGenParameterSpec.Builder(KEY_ALIAS,
                KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
                .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
                .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
                .build();

        // Generate the key using Keystore
        KeyGenerator keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER);
        keyGenerator.init(keyGenParameterSpec);
        return keyGenerator.generateKey();
    }

    public static Cipher getCipher() throws Exception {
        // Create cipher instance for encryption
        Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
        cipher.init(Cipher.ENCRYPT_MODE, createKey());
        return cipher;
    }
}
  

Then you should encrypt the JWT before storing it:

  
import javax.crypto.Cipher;
import javax.crypto.CipherOutputStream;
import java.io.ByteArrayOutputStream;
import javax.crypto.GCMParameterSpec;
import javax.crypto.SecretKey;
import java.util.Base64;

public class EncryptionUtils {

    public static String encryptJWT(String jwt) throws Exception {
        Cipher cipher = KeystoreUtils.getCipher();
        byte[] iv = cipher.getIV();

        // Encrypt JWT
        byte[] encryptionResult = cipher.doFinal(jwt.getBytes());

        // Concatenate IV and encrypted JWT for storage
        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
        outputStream.write(iv);
        outputStream.write(encryptionResult);

        // Return the Base64-encoded result
        return Base64.getEncoder().encodeToString(outputStream.toByteArray());
    }
}
  

To retrieve the JWT, you must decrypt it using the Keystore. You can retrieve the encrypted data, extract the IV, and use the Keystore to decrypt it:

  
import javax.crypto.Cipher;
import javax.crypto.CipherInputStream;
import java.io.ByteArrayInputStream;
import javax.crypto.GCMParameterSpec;
import javax.crypto.SecretKey;
import java.util.Base64;

public class DecryptionUtils {

    public static String decryptJWT(String encryptedJWT) throws Exception {
        byte[] encryptedBytes = Base64.getDecoder().decode(encryptedJWT);

        // Extract the IV from the first 12 bytes
        byte[] iv = new byte[12];
        System.arraycopy(encryptedBytes, 0, iv, 0, iv.length);

        // The rest is the encrypted data
        byte[] encryptedData = new byte[encryptedBytes.length - iv.length];
        System.arraycopy(encryptedBytes, iv.length, encryptedData, 0, encryptedData.length);

        // Set up the decryption cipher
        Cipher cipher = KeystoreUtils.getCipher();
        cipher.init(Cipher.DECRYPT_MODE, KeystoreUtils.createKey(), new GCMParameterSpec(128, iv));

        // Decrypt JWT
        byte[] decryptedData = cipher.doFinal(encryptedData);
        return new String(decryptedData);
    }
}
  

As of Android 10, you can also use EncryptedSharedPreferences. This method handles encryption and key management automatically, making it an easier option for secure storage. Using EncryptedSharedPreferences is an easier and more secure option compared to manually handling encryption with the Keystore.

Here's a simple example using EncryptedSharedPreferences:

  
import androidx.security.crypto.EncryptedSharedPreferences;
import androidx.security.crypto.MasterKeys;

public class SecureJWTStorage {

    public static void storeJWT(Context context, String jwt) {
        try {
            String masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES_GCM_SPEC);
            SharedPreferences sharedPreferences = EncryptedSharedPreferences.create(
                "secure_prefs",
                masterKeyAlias,
                context,
                EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_GCM,
                EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
            );
            SharedPreferences.Editor editor = sharedPreferences.edit();
            editor.putString("jwt", jwt);
            editor.apply();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public static String getJWT(Context context) {
        try {
            String masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES_GCM_SPEC);
            SharedPreferences sharedPreferences = EncryptedSharedPreferences.create(
                "secure_prefs",
                masterKeyAlias,
                context,
                EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_GCM,
                EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
            );
            return sharedPreferences.getString("jwt", null);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }
}
  

Storing JWT in iOS

To safely store a JWT in an iOS app, you should utilize the Keychain. The Keychain is a secure storage mechanism provided by Apple that encrypts data and ensures that it is protected, even if the device is compromised or the app is uninstalled.

First, you need to import the necessary framework and set up the Keychain wrapper.

  
import Security
  

Then, create a Keychain helper class that abstracts away the complexity of using the Keychain.

  
import Foundation
import Security

class KeychainHelper {

    static let shared = KeychainHelper()
    
    // Keychain item attributes
    private let service = "com.yourapp.jwt"
    private let account = "userToken"

    // Save JWT token securely in Keychain
    func saveJWT(token: String) -> Bool {
        guard let tokenData = token.data(using: .utf8) else { return false }

        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
            kSecValueData as String: tokenData
        ]

        // Delete any existing token before saving the new one
        SecItemDelete(query as CFDictionary)
        
        // Add the new JWT token to the Keychain
        let status = SecItemAdd(query as CFDictionary, nil)
        
        return status == errSecSuccess
    }
    
    // Retrieve JWT token securely from Keychain
    func retrieveJWT() -> String? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
            kSecReturnData as String: true,
            kSecMatchLimit as String: kSecMatchLimitOne
        ]
        
        var result: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &result)
        
        guard status == errSecSuccess, let data = result as? Data,
              let token = String(data: data, encoding: .utf8) else { return nil }
        
        return token
    }
    
    // Delete JWT token from Keychain
    func deleteJWT() -> Bool {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account
        ]
        
        let status = SecItemDelete(query as CFDictionary)
        
        return status == errSecSuccess
    }
}
  

Once you have the KeychainHelper class, you can use it to store, retrieve, or delete the JWT token securely.

To add an additional layer of security, you can encrypt the JWT before storing it in the Keychain. This ensures that even if the Keychain is compromised, the JWT is still not easily accessible.

Here is a basic example on how to encrypt the JWT before storing it.

  
import CryptoKit

// Encrypt JWT
func encryptJWT(_ token: String) -> String? {
    let key = SymmetricKey(size: .bits256) // Use a secure key for encryption
    let data = token.data(using: .utf8)!
    
    let sealedBox = try! AES.GCM.seal(data, using: key)
    
    let encryptedJWT = sealedBox.combined
    return encryptedJWT?.base64EncodedString()
}
  

You can also consider using Face ID or Touch ID to add biometric authentication when accessing the stored JWT, especially for sensitive operations.

Best practices for storing JWTs securely

While there are several storage options, the key to ensuring JWT security lies in using the right combination of storage mechanisms and taking steps to mitigate vulnerabilities. Here are some best practices for keeping your JWTs secure:

  • Use secure transport: Always serve your application over HTTPS. This ensures that data, including JWTs, is encrypted in transit and cannot be intercepted by attackers. Never store or send sensitive data over unencrypted HTTP, as this exposes your tokens to potential eavesdropping and man-in-the-middle attacks.
  • Short-lived access tokens: Use short expiration times for access tokens and rely on refresh tokens for session persistence.
  • Rotate refresh tokens: Implement refresh token rotation to mitigate the risks of token theft.
  • Use secure cookies:
    • Set the HttpOnly flag to ensure that the token is only accessible by the server and not by malicious JavaScript code.
    • Set the Secure flag to ensure that the cookie is only sent over HTTPS, preventing man-in-the-middle (MITM) attacks.
    • Set the SameSite flag to prevent cross-site request vulnerabilities.
  • Enable CORS: Ensure Cross-Origin Resource Sharing (CORS) is correctly configured for secure cross-origin requests.
  • Implement token expiration and revocation: Ensure that JWTs have an expiration time (exp claim) and implement a mechanism for revocation if necessary. Expired tokens should no longer be accepted, and revocation lists or blacklists should be in place for tokens that need to be invalidated before they expire.
  • Consider in-memory storage for sensitive data: For particularly sensitive JWTs, consider storing them in memory rather than in persistent storage like local or session storage. This reduces the risk of the token being stolen through storage mechanisms that are accessible by JavaScript. However, bear in mind that in-memory storage can complicate user experience (e.g., requiring re-authentication after a page refresh).
  • Prevent XSS attacks: No matter where you store your JWT, preventing cross-site scripting (XSS) attacks is crucial. XSS attacks occur when an attacker injects malicious scripts into your website that can steal tokens from local or session storage. To prevent XSS:
    • Sanitize and validate all user inputs.
    • Implement a Content Security Policy (CSP) that restricts the execution of unauthorized scripts.
    • Avoid inline JavaScript and use external scripts with integrity checks.

Conclusion

JWTs are a powerful tool for managing authentication and authorization in modern web applications. However, their security is only as good as the way they are stored. By following best practices like using secure cookies, protecting against XSS attacks, implementing short-lived tokens, and using HTTPS, you can keep your JWTs secure and ensure that your application remains safe from common attacks.

Storing tokens securely is an ongoing process, so always stay informed about new threats and security updates to maintain the integrity of your JWT-based authentication system.

This site uses cookies to improve your experience. Please accept the use of cookies on this site. You can review our cookie policy here and our privacy policy here. If you choose to refuse, functionality of this site will be limited.