Last time I worked with some JWT tokens and had to resolve some issues related to security. Right now a lot of web applications and not only, because also mobile apps use these tokens to prove that we are valid and correctly logged-in users. The idea of JWT is clear, right and useful, but there are some dangerous traps during implementation on web applications. The main question is: where should we store JWT tokens to make them secure and still usable?
localStorage and sessionStorage are not good
On mobile clients, it is not a problem, because we can save them in some secure area. The thing is a bit complicated with web applications. If you already thought about this question and looked for some solutions, you probably found a lot of articles about that and some of them recommend using localStorage. During the latest years web developers got a lot of new abilities and localStorage is one of them: simple and very quick to use, it’s just something like:
// Write localStorage.setItem('foo', 'bar') // Read cost myValue = localStorage.get('foo')
Of course, if we allow anyone to make an XSS attack, we have bigger problems… but think about that: in a big, complex web application, can you guarantee that there is no such possibility? We can not think in such a way and just assume that everything will be ok. The better approach is to think that everything will be bad. It is a reason why we do not have to use localStorage for JWT tokens or any sensitive data.
What about sessionStorage? It is very similar: provides a quick, easy way to store some data, but it is not persistent as localStorage. If the user open a new tab or just close and reopen the browser, there will be no data we saved. It may be good for let say banking web applications, but for most others, it will be a very bad user experience. Also, sessionStorage is still vulnerable to XSS attacks. Maybe store them just in memory? It may be a solution, but you will have additional issues with persistence, and it will be very difficult to resolve. Conclusion: such storage are not a good option.
Solution: use old school cookies
The answer is: it will not be possible, but it is not a problem, because browsers will attach these cookies in each request and your API should just check cookies. In reality, cookies are also header, just different and process is automatic, browser will do that for you without any additional code. So, if the user has been authenticated and the API has set a proper cookie with JWT token, it will be automatically sent back, even in background (XHR, fetch) requests. So your frontend application does not have to handle that anymore.
Third: if configured correctly, it will be limited to only secure connections. Right now, all apps with public access should use secure, encrypted connections. Using a secure flag you can define that cookie should be used only on such connections, so if anyone will try to use your application on standard HTTP, such cookie will be not available and not used. Of course, you should still use different methods to provide HTTPS-only traffic: upgrade / rewrite and also send HSTS headers to inform browsers about rules for your websites.
What about CSRF/XSRF issues?
We decided to move all things to cookies, our JWT token is not vulnerable to XSS attack, and we are happy… But just for a moment, because we remembered about different attack surfaces related to cookies: CSRF. Our API sets cookies and it looks fine, but an attacker can right now just prepare a special website and command our clients to send unauthorized requests in the background. Browsers will automatically add cookies in such requests as mentioned above, so it looks like the biggest advantage is also the biggest drawback. What can you do in such a situation? The first thing is to provide strict CSRF protection: send a special token from API to the browser, then attach this token to requests and validate on API: it guarantees that only the real client (i.e. our web application) makes all requests. The problem with that option is maybe we will need to change a lot of places in our code, also handling background requests will be not easy.
Fortunately, there is a second solution, much simpler. We can add another flag to our cookies: sameSite. This option allows us to block sending cookies cross-site. Default option is “None” and it does not block anything, cross-site is possible (also with CSRF possibility). The second option is “Lax” – with such configuration browsers will not send cookies for images and frames, so it will prevent CSRF attacks. If the user clicks the URL to our website on a completely different website (or email), cookies will be added, so everything will work correctly. The latest option is “Strict”: in that case cross-site cookies are completely blocked, the most secure, but in some cases a bit limited option.
Set-Cookie: jwt=OUR_TOKEN_CONTENT; secure; httpOnly; sameSite=Lax;
What about refresh tokens?
Exactly the same thing – they are also super important because they allow users to generate new JWT. If you use this token to determine: is a user logged in or not, you can stop doing that. Just save such information in localStorage (simple bool) if you need. It is not a problem: if the token expires, your API will inform the frontend about unauthorized requests, and then you can call the proper service to refresh the token. If the refresh token is expired or invalid, this service will inform you about that, so you will know that the user should be redirected to the login page. Yep, all these things without using tokens manually, it will be completely transparent from web application level.
Authorization service on different domain
Bonus case: what if our authorization service is on a completely different domain or subdomain? Let’s say we use oauth.mydomain to display and handle login pages, but our users will use different websites like app.mydomain. In this scenario, cookies will not work correctly, so what can we do? There are two options. First is to move login into the app subdomain or just the main domain (then cookies will be available on all subdomains – you can still control that) – may in many cases it may be not possible or just very, very complicated because we want to support many systems. Second option: just provide a proper URL on your app and use it like a “middleware” to call authorization service, so it will be Web App ⇔ API ⇔ Authorization Service. If oauth respond with correct data, you can set proper cookies for your current domain.