DEV Community

Cover image for No, storing JWT in the local storage is not the issue
Amin
Amin

Posted on • Updated on

No, storing JWT in the local storage is not the issue

Disclaimer: anything taugh here should be used in a controlled environment with the aggrement of the target application's author and/or owner if applicable. I can't be held responsible for any damage done by any of these informations.

I've heard a lot for some years now that JWT should not be stored in the local storage, that it is a vulnerable storage, and that usually does not follow any rationale about why we should not and what we can do as an alternative to use JWT on the client side besides using sessions.

I wanted to make an article as a record of all I've seen, both good and bad habit of using JWTs on the client side and what we can do to maximize security when using JWTs on our Web applications.

What is a JWT

A JWT, short for JSON Web Token, is a mechanism for authenticating and authorizing a user which has the benefit to not require any persistent storage, contrary to sessions for instance.

This means that you can have a server located at https://auth.server.com that issues JWTs that can then be used and validated by another server located at https://users.server.com without having to share any database since this JWT has all the necessary information inside of it to be validated and to retrieve its data.

You'll often see this be used in microservice architectures where it shines best, although you can also use it when separating your clients from your servers or when communicating with multiples clients that do not always supports sessions as a mechanism for authentication & authorization.

How does a JWT work?

It is composed of three parts: a header, a payload and a signature.

The header contains metadata about the JWT, such as its type, and the algorithm used for signing the JWT.

The payload contains all the relevant informations that we wish to store in order to retrieve them later on another server, on any request.

Finally, the signature is the authenticity mark that has been left by the server and that serves as a stamp of authority for any subsequent requests.

Without the signature, anyone could reforge a JWT and update its informations, mostly the payload, and as clients should never be trusted blindly, tokens should be verified against a known secret.

Weak signature

Going technical here, a signature is constructed from the header & the payload that are encoded using the base64 algorithm and signed altogether using the HMACSHA256 algorithm (there are also other algorithms, but chances are you are using this if you have not configured JWTs deeply in your projects).

const header = {
  "alg": "HS256",
  "typ": "JWT"
}

const encodedHeader = base64encode(header)

const payload = {
  "sub": "1234567890",
  "name": "John Doe",
  "id": 17392,
  "email": "john@doe.com",
  "iat": 1516239022
}

const encodedPayload = base64encode(payload)

const secret = "secret"

const signature = hmacSha256(
  `${encodedHeader}.${encodedPayload}`,
  secret
)

const jwt = `${encodedHeader}.${encodedPayload}.${signature}`
Enter fullscreen mode Exit fullscreen mode

If we were to use a pseudo-code (mostly in JavaScript style), this is what we would be doing for constructing such JWT.

Notice here that we are using a very weak secret, that is commonly found in many tutorial out there on the internet.

If we were feeling lazy, we would follow this tutorial, copy-paste this code, spin up a server in production and let the user login and give them their JWTs.

But this would probably be a bad idea to use such secret named secret since it can easily be guessed, instead, we should hardened our secret to be something tougher like the one below.

const secret = "8fr8YQvqpWYbaAiPsWde"
Enter fullscreen mode Exit fullscreen mode

If you are using a GNU/Linux or UNIX based operating system, you can generate random strings using openssl.

openssl rand -base64 15
Enter fullscreen mode Exit fullscreen mode

This way, our secret is harder to guess and brute-force, and thus our JWT is harder to reforge.

Reforge a JWT

If you would like to reforge a JWT, all you have to do is to take the target JWT, try and guess its secret, and tada! You are now able to update its content, reforge it using the steps above, update the payload for instance, and send it to the server.

If you don't know what the JWT secret is, you can always use the good'ol brute force attack, or use a rainbow table in order to try and guess the secret. A rainbow table is a set of commonly used passwords that are stored in files and can be used in specialized pentesting and red-team tools.

In order to mitigate this vulnerability, you can use a harder to guess secret, with lots of characters, symbols, numbers, in order to make the work of a hacker harder, to the point that he will give up on cracking the code.

JWTs are encoded, not encrypted

Another attack vector that might be easier than anything we have seen is to simply observe the content of a JWT.

To my surprise, not so many people are familiar with the concept of encoding and encrypting so I'll try to explain this concept as best as I can.

Encoding & encrypting are both ways of creating data that has no direct meaning for a human, but is ideal for transportation and security.

But they differ in the way they are created.

Encoding is a reversible operation that can be done using a publicly available encoding algorithm such as base64.

Encrypting is also an operation that is reversible, but it generally involves using a piece of information that is not publicly available.

Apart from the signature, both the header & the payload are encoded, which means that they can be easily reversed and observed by anyone, anywhere, anytime.

This, alone, is not a vulnerability and is simply a design path taken by the authors of the JWT RFC.

However, many people are confused by the two meaning and don't go the extra mile trying to understand how a JWT really works under the hood.

We are often blinded by the fact the the JWT ressemble a totally random string, so it must be encrypted and secure, right? Right??

Well, not at all, since any data that we might want to transport throught the JWT from clients to servers is located inside the payload part, we take the risk of exposing sensitive data, or to allow a hacker to be able to reforge a token with whatever data he wants if he gets a hold on the secret.

This alone might be the biggest misconception about JWT's security features.

Session good, LocalStorage bad

Another misconception about JWTs is that they should not be stored in the LocalStorage of your browser.

I've heard this saying a lot, and I've definitely read about this a lot on the interwebs, like this article from Masa Kudamatsu here on DEV saying:

"Don't store JWTs in localStorage, which is susceptible to cross-site scripting (XSS) attacks"

Although I've never had the opportunity to find a clear answer to that point since LocalStorage exists for a long time now and if it was ever flawed or considered a threat, it would have been patched or removed since then.

What's really vulnerable is not the storage itself (which is implied in the quote above) but rather to store vulnerable JWTs in the first place, whatever the storage used.

Or even having a JavaScript library that has a security vulnerability, which leads to a data disclosure (the consequence of this vulnerability is the localStorage being exposed by such threat).

Of course, you can retrieve data easily from the LocalStorage if you get to discover a vulnerability on the client-side JavaScript library that is used by a website using an XSS for instance, but if it is really the case, you have a lot more to fear than a JWT being stolen at that point.

What's really vulnerable is that you are leaking a JWT that is vulnerable, through another vulnerability on a client-side JavaScript library.

If we were to mitigate this threat by using a stronger secret, and not leaking any sensitive informations through the payload of our JWT, this "vulnerable" JWT would have been impossible to exploit by any remote hacker out there, regardless of the client-side JavaScript library that you are using, or the state of its CVEs for a given version.

To make a better statement, don't store JWT in localStorage if you are using JavaScript libraries that are vulnerable to cross-site scripting attacks (XSS). Otherwise, it is totally safe to do so, as long as you use hardened JWTs.

Client side decoding

Another funny thing I've noticed in my recent work with my clients is that it is common to extract data from a JWT using the secret on the client-side, that should normally remain secret and on the server-side.

For instance, if using Vite.js, one will have an environment variable named VITE_APP_JWT_SECRET that exposes the JWT secret, in order to use the verify method on any issued JWT only to display a first name, or an email address that has been stored in a JWT.

First of all, the user's first name or email, or any other informations might be updated by an administrator, and you now have an unsychronized data displayed on the client App, not terribly great for the user experience but it gets worse.

Even if this is something that you might want to do, verifying and decoding a JWT token is not at all necessary if you understood well how a JWT works since it would be easier to split the JWT by dots, grab the second part (the payload) and decode it using a base64 decode function. You get the same exact behavior, without having to leak your secret.

All of that, only to save some precious CPU clocks, memory usage and network bandwidth because it is cheaper and might reduce the AWS Lambda Function invoice at the end of the month.

If you are part of that team, I'm in the regret to say that you might save the equivalent of an ice-cream by not having to have an HTTP request to check the authentication state of the current user each year.

And that you have instead lost million of dollars in lawsuit cases with your customers because you now have a fast and vulnerable Web application, congrats!

Rather, you should have your server return some data by providing a JWT token. This is the only safe and reliable way of having the data that you want, and if you go this path, you might also not want to store all that information inside of your JWT token and might as well use a unique identifier in order to later retrieve the user's information.

id INTEGER AUTO_INCREMENT PRIMARY KEY

Another threat that might be mitigated is the use of integers that are auto incremented as the default primary keys of our tables.

As a student, I have manipulated this data structure a lot when using SQL databases, simply because MySQL & PostgreSQL were the easiest databases to setup out there. They are free, they are great, they are fast and there is a lot of documentation to be able to ship a product with literally $0.00 spent on database licensing and stuff.

However, the fact that this is something that is often taught, by opposition of any known alternative for primary keys, is something that insuflate fear to my body once I realised that they were also used in JWTs as a way of remembering what users are attached to what JWT.

Remember, JWT by opposition to sessions are stateless, so we have to figure a way of knowing which user has been issued a JWT, otherwise it becomes pointless from the point of view of authentication & authorization.

If we think about it for a while, it seems a logical choice to use an ID for linking a JWT to a user.

They are often used as a primary key, which makes it almost impossible to mistake a user from another, they don't leak any sensitive informations such as the name, email or address, and they are easy to store and serialize.

However, it is not all sunshine and rainbows unforatunately for our beloved integer auto incremented primary keys.

If we, by mistake, deploy a server that has a vulnerable secret, as I said earlier, anyone can reforge a JWT and make it reflect any data they want through the payload.

Now, if we use receive a JWT on the server-side from a request, we might want to verify, decode, and get the ID back from the payload in order to retrieve the user that is attached to the JWT (presumably, the user that has logged in) and send them informations about their address, age, social numbers, etc...

However, if we find the vulnerable secret, it would be dead easy to guess that the user with id 147 is an auto increment, and as such, it would also be a good guess to say that the user with id 1 is the administrator.

Hell, even if this is not the case, we might create a simple script that sends HTTP requests with reforged JWTs that use an id that is incremented, until the server returns an error or we are good enough with the thousands of user's data that we exfiltrated.

This is a problem, but using integer as our primary key is not the only solution to uniquely identify users.

Oh, UUID my UUID

Surprisingly to me, UUIDs are not that often used in the databases that I've managed for my clients, although they are powerful ways to create unique strings of data, while being (almost) impossible to guess by a human (as long as you use the version 4 at least, please use version 4 or later).

A UUID looks like this.

160f967c-ca2a-42ae-9161-1db04ba3831a
Enter fullscreen mode Exit fullscreen mode

And are supported by any SQL database since they can be stored as strings.

And even if you database does not support UUIDs natively, you can always resort to use a programming language specific library that implements the UUID specification and use it to generate your ids.

That way, even if a hacker gets a hold on one of our issued JWT, all he would be able to do with the secret is to try and guess another randomly generated UUID, and chances are that he won't be able to guess what is the UUID of the administrator user, nor the ones from the other users in our database.

Performance

I'll be quick on this sentence: if you value performance over security, there is nothing good for you to take here since all the techniques that we have followed up until now are necessarily slower.

Now, when I say slower, you won't even notice the performance difference in any of your applications, ever. And if you do, chances are that you don't have the time to read an article made by a nobody like me on the internet and you might be as well be working on your next billion users' company now.

Even if performance is a concern for you, there are databases that are mostly adapted for fast read/write operations such as NoSQL databases like MongoDB or Key/Value databases like Redis that might help in speeding things up when validating and retrieving users from a JWT for instance.

And if you manage to notice some performance issue with all this, you might be as well be working for Google or Amazon and in that case, I'm honored you read one of my article.

Conclusion

LocalStorage, SessionStorage, or any storage is not vulnerable unless you make it so.

We have seen that a vulnerable JWT might be because:

  • you are using a vulnerable library
  • you are using a weak secret
  • the JWT has some leaked informations in it while thinking that JWT are encrypted and not encoded
  • you have used integers as the identifiers for your users and have stored them in your JWTs.

The quick and easy fix for all that is to use better secrets and not be tempted to copy/paste them from a tutorial, not be tempted to decode a JWT on the client side and not storing any sensitive information is also a good way to mitigate any attacks while using a more robust and secure way of identifying your users might as well add another layer of protection out there.

These operation are amazingly simple to do on any app, yet powerful enough to prevent most hackers and script kiddies from attempting to find a way of stealing you or your users' informations.

Find this article useful? Want to add some more insightful informations? Feel free to hit the comment section down below!

Top comments (2)

Collapse
 
fjones profile image
FJones

Encoding is a reversible operation that can be done using a[n] [...] encoding algorithm such as md5 [...]

I mean, granted, md5 is somewhat "reversible" these days, but... No.

Collapse
 
aminnairi profile image
Amin • Edited

Correct, I'll remove this part in this article as this is might create unecessary debate about whether md5 is reversible or not since this is not the point of this article. Thank you for your insight!