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:
In this post, I will summarize my findings and comment on the solution I ended up with.
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:
<head>
of our pageIf 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.
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:
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:
Cons:
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:
Cons:
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:
Cons:
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.
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.