This tutorial was written by Ivan Kahl, a Johannesburg-based software developer specializing in .NET and AWS. Connect with him on his website or X to see more of his work!
JSON Web Tokens (JWTs) are compact, self-contained tokens used to securely transmit information between parties while maintaining data integrity. Many authorization providers use JWTs to provide a tamper-proof way of identifying an authenticated user in an application and checking what they’re authorized to do.
Unfortunately, securely storing JWTs is often overlooked, which can expose some serious vulnerabilities. For example, cross-site scripting (XSS) attacks can steal JWTs, letting malicious actors impersonate users and access sensitive data. Therefore, you need to store JWTs securely to protect users and potentially save your organization millions by preventing data breaches.
This guide will explore essential JWT security considerations, popular browser storage methods, and their benefits and drawbacks, best practices, and troubleshooting tips for working with JWTs. By the end, you’ll be well-informed about the available JWT storage methods and choose the most appropriate solution for your use case.
Understanding JWT storage
JWTs are compact, self-contained tokens used to transmit user information in requests to a server. Given HTTP’s stateless nature, once an application receives the token, it must be stored and used for subsequent API requests to the server.
The JWT lifecycle consists of three stages: creation, storage, and usage. First, an authorization server generates the JWT after the user is successfully authenticated. Second, the application stores the token, typically in the browser. Third, the application includes the token in the authorization header when making requests to the resource server, which verifies the token before processing the request.
Here’s a diagram of the process:
Security is critical when storing JWT storage. Storing it improperly can lead to token theft, making it possible for attackers to impersonate users or elevate their privileges. Common attack vectors include the following:
Arbitrary signature attacks: Arbitrary signature attacks occur when servers fail to verify token signatures, allowing attackers to modify claims and escalate privileges or impersonate users. While this is primarily a server-side issue, secure JWT storage makes it harder for attackers to obtain a valid token to study and manipulate.
none
algorithm attacks: Thenone
algorithm attack happens when servers mistakenly accept unsigned JWTs due to thealg
parameter being set tonone
. JWT storage is not entirely related to this attack. However, it’s harder for attackers to put together a malicious token if they can’t gain access to the valid one.Algorithm confusion attacks: In algorithm confusion attacks, attackers exploit mismatches between the signing and verification algorithms, generating valid tokens with their own keys. If tokens are stored insecurely, attackers can more easily obtain them to study the algorithm used and attempt to craft tokens with manipulated algorithms and secrets.
kid manipulation: Key ID (kid) manipulation exploits vulnerabilities in the kid parameter by injecting malicious commands into the token’s key verification process. As with other attack vectors, kid manipulation is more straightforward if attackers obtain a valid JWT. However, the vulnerability needs to be fixed on the server side.
Brute force attacks: Brute force attacks target weak or simple symmetric encryption secrets, allowing attackers to generate malicious tokens by guessing the secret. Secure JWT storage is crucial in preventing brute-force attacks. If tokens are stored insecurely, attackers can easily obtain valid tokens to use as a reference when attempting to guess the secret.
To mitigate these risks, it’s crucial that you implement proper server-side token verification, disable insecure algorithms, use strong encryption, and validate all parameters in the token. When storing tokens in a client-side application, you must do so securely to prevent unauthorized access to valid tokens, which attackers could study and manipulate.
Common JWT storage methods
Several options are available for storing tokens client-side in the browser. Each has its strengths and weaknesses, and you must assess your application requirements and decide which technique is best suited.
For example, session or in-memory storage might be ideal if user sessions are generally short and get logged out when they’re done. However, you might consider cookies or local storage for longer-lived user sessions.
Local storage
Local storage provides a simple API for storing user data across sessions in the browser. Its simplicity and robustness across sessions make it a popular choice for storing JWTs in the browser. However, it’s important to note that local storage is vulnerable to XSS attacks, where attackers can inject malicious scripts to access stored data, including JWTs. Make sure to implement appropriate safety measures to prevent this.
Run the following code to store a JWT in local storage after receiving it:
// Store JWT
localStorage.setItem('jwtToken', response.data.token);
// Use JWT in request
const token = localStorage.getItem('jwtToken');
const response = await fetch('https://example.com/api/data', {
headers: {
'Authorization': `Bearer ${token}`
}
});
// Clear JWT (log out)
localStorage.removeItem('jwtToken');
Local storage has some pros and cons that you should carefully consider:
Pros:
Easy to implement
Accessible from JavaScript code
Accessible across browser tabs
Persistent between page refreshes
Generous storage limit (5MB)
Cons:
Vulnerable to XSS attacks
No automatic token expiration mechanism
Local storage is a popular choice for storing JWTs as it lets you persist tokens across pages and is easy to access from JavaScript. However, make sure you patch XSS vulnerabilities to prevent malicious actors from stealing tokens.
Cookies
Cookies offer additional security compared to other client-side storage options. They require server-side modifications to create secure cookies containing the token. When properly configured, cookies are less vulnerable to XSS attacks, but they can still be susceptible to cross-site request forgery (CSRF) attacks.
Use the HttpOnly
flag to ensure client-side JavaScript cannot access the cookies. The Secure flag ensures the cookie is sent only over a secure channel that’s not susceptible to man-in-the-middle attacks. Finally, the SameSite
attribute can be used with other anti-CSRF strategies to help prevent CSRF attacks.
Here’s a basic example of storing a JWT in a cookie using Express:
app.post('/login', (req, res) => {
const token = generateJWT(user);
// Insert the token into the `auth_jwt` cookie in the response
res.cookie('auth_jwt', token, {
httpOnly: true,
secure: true,
maxAge: 60 * 60 * 1000 // 1 hour
});
});
Run the following code to make a request with the cookie in client-side JavaScript code:
const response = await fetch('/protected', {
method: 'GET',
// Set credentials to same-origin so cookies are included
credentials: 'same-origin'
});
The following is to verify the token on the server using Express:
app.get('/protected', (req, res) => {
const jwt = req.cookies.auth_jwt;
if (!verifyToken(jwt)) {
return res.status(401).json({ message: 'Token is invalid' });
}
// Continue processing the request here since the JWT is valid
});
Logging the user out involves simply clearing the cookie:
app.get('/logout', (req, res) => {
res.clearCookie('auth_jwt');
return res.status(200).json({ message: 'User logged out.' });
});
Consider the following pros and cons regarding cookie storage before using it for your JWTs:
Pros:
Enhanced security against XSS attacks (with
HttpOnly
flag).Automatically included in requests.
Built-in security features (
Secure
flag andSameSite
attribute).Server-side control over token storage lets the server revoke tokens if necessary before processing requests. This could happen if a user logs out of another system and a back-channel logout request is sent to the server.
Accessible across browser tabs.
Persistent between page refreshes.
Cons:
Vulnerable to CSRF attacks (mitigate with the
SameSite
attribute and anti-CSRF mechanisms).Limited storage capacity (around 4KB).
Often requires additional server-side logic.
Possibly tricky to work with when working across domains.
Cookie storage is a great solution as it provides a good balance of security and functionality. However, remember to implement proper security measures, like using the HttpOnly
, Secure
, and SameSite
flags, along with anti-CSRF mechanisms.
Session storage
Session storage is a JavaScript API in the browser that lets you persist data for a single page until it’s closed. This means that the data stored is lost as soon as the page is closed. Also, if a user opens another page in a different tab or window, it will have its own session storage.
Session storage is slightly more secure than local storage, as session storage gets cleared when the page closes. However, session storage is still vulnerable to XSS attacks. Use the OWASP cheat sheet and a Content Security Policy to prevent these attacks. However, be aware that malicious browser extensions can still access session storage.
Here’s how to use session storage for JWTs:
// Store JWT
sessionStorage.setItem('jwtToken', token);
// Retrieve JWT
const token = sessionStorage.getItem('jwtToken');
// Use JWT in request
const response = await fetch('https://example.com/api/data', {
headers: { 'Authorization': `Bearer ${token}` }
});
// Remove JWT (logout)
sessionStorage.removeItem('jwtToken');
Before using session storage, consider the following pros and cons:
Pros:
Easy to implement
Accessible to client-side scripts
Automatic clearing of tokens when the page is closed
Generous storage limit(5MB limit)
Cons:
Vulnerable to XSS attacks
Lost token between page refreshes and tabs
Session storage offers a simple way to store JWTs and automatically clears them when the page is closed. Tokens are also accessible from client-side scripts. However, be sure to protect your app against XSS attacks to prevent stolen tokens.
In-memory storage
In-memory storage keeps the JWT in the web application’s memory using a JavaScript variable or a web worker, rather than persisting it in browser storage. This approach is helpful in single-page applications (SPAs) where the page doesn’t refresh often.
In-memory storage can be considered slightly more secure since the token is obscured from the attacker. Attackers can’t use a standard browser API to retrieve it. However, with enough patience, an attacker could still figure out where the token is being stored and retrieve it through an XSS attack. Malicious actors can also try to access the in-memory JWT using a debugger. Similar to other storage techniques, having a well-defined Content Security Policy and implementing XSS preventive measures are vital.
The following snippet demonstrates how to store a JWT in memory using a JavaScript class with a private variable storing the JWT:
class TokenService {
#token = null;
setToken(newToken) {
this.#token = newToken;
}
getToken() {
return this.#token;
}
clearToken() {
this.#token = null;
}
}
const tokenService = new TokenService();
// Store JWT
tokenService.setToken(response.data.token);
// Use JWT in request
const response = await fetch('https://example.com/api/data', {
headers: {
// token is the global variable
'Authorization': `Bearer ${tokenService.getToken()}`
}
})
// Clear JWT (logout)
tokenService.clearToken();
In-memory might seem slightly more secure than some other methods, but it still has some cons, which might influence your decision to go with this approach:
Pros:
Harder to retrieve via XSS attacks
Accessible to client-side scripts
Automatic token clearing when navigating pages and tabs
No storage limitations
Cons:
Still vulnerable to sophisticated XSS attacks
Lost token between page refreshes and tabs
In-memory offers a balance between security and convenience. It’s slightly harder to retrieve using XSS attacks, but you should still take preventive measures to prevent these attacks in the first place.
Best practices for JWT storage
Regardless of which storage method you use, there are some best practices you should follow to minimize authentication and authorization vulnerabilities in your application.
Encryption of stored tokens
Storing tokens unencrypted makes it possible for an attacker to decipher the token and see what claims are associated with it. JSON Web Encryption (JWE) is a standardized method of encrypting JWTs. The encryption happens on the server before the token is sent to the client, and the encryption keys are kept on the server or in a dedicated key management service.
Since the client cannot access the encryption key, they cannot view any of the claims in the token.
Token expiration and rotation
JWT expiration times dictate when a token is no longer valid. Once a token expires, you must retrieve a new one using a refresh token. A refresh token is a token issued along with the original JWT that lets you request a new JWT when the current one expires. When issuing a new JWT using a refresh token, the authorization server first ensures the user’s session is still active and then sends the new token if it is.
You can further enhance the security of your JWT and refresh token by rotating the refresh tokens. This means that every time you request a new JWT using a refresh token, a new refresh token is generated along with the new token and returned. The refresh token returned is the only valid token that can be used to get the next JWT.
You should also set an expiration period for your refresh tokens, as this adds an extra layer of security. Even if the attackers can access the refresh token, they might not use it in time.
The Developer's Guide to Refresh Token Rotation
Handling of token revocation
If a server uses only the JWT expiry time claim to determine whether a token has expired, then a token can still be valid after a user is logged out or blocked from an application. While shortening the JWT expiry time can help minimize the risk, there’s still a window of time where a token is valid after the user’s session has ended.
By implementing a blocklist, the server can use the token’s expiry time and the blocklist to determine if the token is valid. When a user logs out or their account is banned, the authorization server sends a back-channel logout request to other servers, indicating that this user’s session has ended. Each server can use this event to update its blocklist with the user’s details. Then, when another request is sent using that user’s JWT, it can get picked up as invalid even if it hasn’t expired yet.
While most applications are okay with a JWT lasting a little longer than a user session, some might require stricter security. Implementing token revocation lets you derive the benefits of JWT while offering an efficient way of immediately blocking tokens.
Protection against XSS and CSRF attacks
Every storage method discussed in this article can be prone to XSS and CSRF attacks.
To prevent XSS attacks, use cookie authentication so JavaScript can’t access the JWT. However, if your frontend JavaScript needs access to the JWT, you can implement a Content Security Policy to restrict which scripts can run on the page.
If you use cookie authentication, ensure your cookies are set up correctly. They should be HttpOnly
and marked as Secure
. Additionally, configure SameSite
to restrict when the cookie gets sent in requests from third-party URLs. Other anti-CSRF mechanisms, like anti-forgery tokens, can also help secure against CSRF attacks.
There’s no perfect solution when it comes to XSS and CSRF attacks. Assess your requirements, decide which storage mechanism you will use, and then cater to the vulnerabilities that come with that method.
Troubleshooting common issues
JWTs can be challenging to troubleshoot when things go wrong. The following are some useful tips for troubleshooting JWT issues.
Debugging JWT storage issues
If you’re struggling to figure out if the JWT is being stored successfully or not, use the Application tab in your browser’s developer tools to analyze the local storage, session storage, and cookies associated with the current page:
If the token appears in your chosen storage mechanism, verify that the token is in the correct format. It should consist of three parts separated by dots.
You can also monitor the lifecycle of the token logging messages. This can help identify where the token is not being processed or stored correctly in your code.
Handling token expiration elegantly
Tokens expire, so make sure you implement refresh tokens and use them to retrieve new tokens when the old ones expire.
When using refresh tokens, it’s better to proactively refresh a token before it expires by monitoring its expiry time and triggering a refresh a few seconds before the expiry time. Otherwise, you might send a token that’s still valid for a split second when the request is sent, but it expires when the server receives it.
If a refresh request fails, retry it as a network issue could be the cause. If it fails again, redirect the user to the authentication screen with a message notifying them that they must log in to continue using the application. This makes your application more robust when it encounters authentication errors.
Dealing with cross-domain storage hurdles
Cookies are typically associated with a specific domain, and the browser sends the cookie only when a request is made to that particular domain. However, what if your application needs to make requests to multiple domains using the same token? For example, an application’s frontend might be on a different domain than the API.
If so, you should consider a backend-for-frontend (BFF) for your application. A BFF consolidates all those API calls to different services into a single service. This way, your cookie needs to be configured only for your BFF’s domain. The BFF can then proxy the request to the appropriate service with the JWT.
JWT storage on mobile
Until now, you’ve seen how JWTs can be stored in a browser context. But what about mobile apps? Many apps need to access restricted APIs that require authentication.
JWT storage solutions like HttpOnly cookies work well in a web application but are more complex in mobile apps, given the absence of browser-specific mechanisms like cookie management. Your app would need to manually store the cookie and attach it to every request to the API. When storing the cookie, you also need to make sure it’s secure and not accessible by other apps or the end-user.
Fortunately, mobile platforms offer secure storage to store sensitive secrets like JWTs. On Android, you can use Keystore to encrypt JWTs, which you can then store using SharedPreferences. Similarly, iOS lets you use Keychain to encrypt and store secrets on the user’s device. Using these services is similar to how you’d store JWTs in local storage on the web: after the user logs in, retrieve the JWT and store it.
When storing JWTs in a mobile app, remember that these devices are more prone to theft, so create short-lived JWTs that get rotated often using refresh tokens. You could even store a session identifier instead of the JWT in the mobile app. With a session identifier, the app makes a request to the server, which does a database lookup to retrieve the associated JWT and validate it.
While slightly more complex to set up, this approach makes it even harder for a malicious user to access the underlying JWT since it’s stored and accessed on the server. This solution also gives you more control over mobile app sessions since you can terminate a session with immediate effect, unlike JWTs, which only end a session when they expire (or are added to a blacklist, as discussed above).
Conclusion
In this guide, you’ve discovered how local storage, session storage, cookies, and in-memory storage can be used for storing JWTs securely in web applications. The guide discussed the security implications of each approach and some pros and cons. You were then introduced to some best practices that should be followed regardless of your storage method. These include encrypting the JWTs, expiring tokens and rotating their refresh keys, handling token revocation, and protecting against XSS and CSRF attacks. Finally, the guide also provided insights into securely storing JWTs in mobile applications.
JWTs are one of the most popular forms of authentication in modern systems. As new use cases emerge, JWTs are tweaked to work in those scenarios. For example, JWTs were initially designed to be stateless, meaning servers could verify a token without looking it up in an external system. However, some use cases require immediate token revocation, which brought about stateful JWT authentication, where the server also stores information about the token to verify it’s still valid.
The stateless nature of JWTs has also made them a popular choice when building scalable applications since each server can verify the token by decoding the token passed in requests. JWTs and their use cases are evolving constantly, and it’s crucial that you continuously reassess your application’s authentication configuration, including how you store JWTs in applications, to ensure no new security vulnerabilities or loopholes appear.