I recently had to build a RESTful backend for a new application and had to decide which authentication mechanism to use. Typically, I would use HTTP sessions. However, this app was going to have both web and mobile clients, and I had been reading about how JSON Web Tokens (JWT) have become the de-facto authentication mechanism for mobile apps, so I decided to give them a try.
As I started doing some reading on JWT, I was a bit surprised by the lack of consensus on a couple of different topics:
- Where should JWTs be stored
- How to prevent common attacks like CSRF and XSS
- How to revoke tokens in case they are compromised
In this post, I will summarize my findings and comment on the solution I ended up with.
Where to store JWTs — Cookies vs Web Storage
There is much debate on whether JWTs should be stored in the browser using HTML5 Web Storage or traditional HTTP cookies.
The main problem with Web Storage — and the reason why I decided not to use it — is Cross-Site Scripting Attacks (XSS). Since Web Storage is accessible through Javascript, this exposes us to several attack vectors:
- rogue third party libraries that we might include in our code
- rogue scripts running in the same web page, e.g., an analytics script we might include in the
<head>
of our page - malicious user input that we might render as dynamic content
If we store the JWT using HTTP cookies with the HttpOnly
cookie flag, then Javascript never has access to the JWT, thus mitigating XSS attacks. If we combine it with the Secure
cookie flag, then the JWT will only be sent through HTTPS, protecting it against Man-in-the-middle attacks.
However, there is one big threat for HTTP cookies: Cross-site request forgery (CSRF). Since cookies are sent by the browser by default, an attacker might trick a user into clicking on a malicious link and submitting an authenticated request to our app.
The good news is that CSRF attacks can be mitigated with a very simple solution: requiring all requests to include a token, usually called a "CSRF token". The token can be sent to the client on login, and then required as a header on every future request.
Conclusion: Even though both Web Storage and HTTP cookies can be compromised through different attacks, CSRF attacks are easier to mitigate than XSS attacks, so I decided to go with HTTP cookies for web clients.
Since mobile clients do not suffer from XSS attacks, on the mobile clients I opted for local storage.
Bonus: you can include a copy of the CSRF token as part of the JWT claims. This enables CSRF validation on your server to be stateless.
Dealing with compromised JWTs
There are 2 common scenarios in which a JWT might be compromised:
-
An authenticated device (like a laptop or phone) is lost or stolen.
In this case, we should be able to revoke all tokens for a user, i.e. log them out from all devices. -
The JWT is stolen: either through a Man-in-the-middle attack, a memory leak, or some other attack.
This is a trickier case, but we can mitigate it by detecting irregularities in the client requests. For example, if a client makes requests from different IP addresses or sends conflicting user-agents, we might want to revoke the JWT and require re-authorization.
I found a couple approaches for dealing with compromised JWTs:
1. Long-lived JWT + Validation on every request
When a user logs in, emit a long-lived JWT. Also, keep a database record per JWT, containing a revoked
flag and any additional information you want to keep for the client (IP address, user agent, etc). On every request, validate against the database record and reject the request if validation fails.
Pros:
- Easy for the client to use. The only concern for the client is to re-authorize the user if a request is rejected.
- Tokens can be revoked immediately, as soon as an irregularity is found or a user reports a device as stolen.
Cons:
- Every request needs to hit the authentication service for validation, making horizontal scaling harder (one of the selling points for using JWTs).
2. Short-lived JWT + Long-lived refresh token
When the user logs in, emit two keys: a short-lived JWT and a long-lived random token — called a refresh token. Keep a database record for the refresh token, not the JWT. The refresh token is used to generate new short-lived JWTs, through a special "refresh JWT" API endpoint.
On every request, check the JWT’s expiration date (which is self-contained in the JWT).
If the JWT expired, the request is rejected, and the client is forced to generate a new JWT.
When a "refresh JWT" request is received, validate against the database record. Do not generate a new JWT if validation fails.
Pros:
- JWT verification per request is stateless/decentralized, making horizontal scaling easier.
Cons:
- Makes client implementations more difficult. The client now needs to deal with expiration times for JWTs, either by refreshing the token before making a request or by catching response errors, then refreshing the token and retrying.
- Decreased security. Since we only keep a database record for the refresh token, we cannot revoke a JWT directly — we can only revoke the refresh token and wait until the JWT expires. There might be a time window in which we have already revoked the refresh token, but the associated JWT can still be used by a perpetrator.
3. Short-lived JWT + Validation on expiration
This is the implementation I ended up with, and it is an in-between of the previous two. When the user logs in, emit a short-lived JWT, and keep a database record for it.
On every request, check the JWT’s expiration date. If the JWT expired, try refreshing it by validating against the database record. If validation succeeds, perform the request and return the fresh JWT as a cookie or a custom header along with the response. Otherwise, reject the request.
In other words, move the refresh token responsibility away from the client and into the API.
Pros:
- Easy for the client to use. The only concern for the client is to re-authorize the user if a request is rejected.
- JWT verification per request is stateless/decentralized, making horizontal scaling easier.
Cons:
- Decreased security. There is a time window between "user revokes access" and "access is guaranteed to be revoked".
Conclusion: Even though I went with the "Short-lived JWT + Validation on expiration" option, I now think the "Validate on every request" option is better suited for most applications. The only reason to use one of the other options is if you REALLY need decentralized authentication — which most apps can live without — and you can afford the insecure time window that comes with it.
Will I use JWTs for future apps?
Not likely. I am a big fan of simple and battle-tested solutions (both attributes of HTTP sessions). Furthermore, mobile frameworks have support for HTTP cookies, so I do not see a reason why HTTP sessions would not work for mobile apps. Finally, I believe the decentralized validation of JWTs (its biggest selling point) is not really needed for most applications.
I really recommend reading this post: Stop using JWT for sessions, which goes into way more detail about why HTTP sessions are better suited for authentication.
That being said, I am open to hearing your thoughts about it. Feel free to leave your comments below.