Getting Token Authentication Right in a Stateless Single Page Application
Single page application architecture is becoming more prevalent, yet many established patterns to implement authentication security and user experience have not caught up. Patterns used by traditional web applications do not cross over well, or at all, to a true stateless architecture where there is no server-side web session.
The popularity of JSON Web Tokens (JWTs) is well deserved. This fantastic evolution is a building block for solving these problems. As JWTs provide a simple, cryptographically secure means of exchanging information, they make stateless authentication possible. However a building block is just that: a place to start. In going through all the requirements to produce a truly professional SaaS authentication/authenticated user experience in the Lightrail front-end (a stateless single page app backed by the same RESTful API that is published and exposed to external customers), we found that there were gaps in emerging best practices when it comes to a polished customer experience.
This post discusses in detail the JWT based authentication strategy and architecture we decided on, and the technical and user requirements that led us there. Key areas that are covered:
- Security versus convenience in authentication approaches
- Multi-factor (2FA) authentication
- The essential parts of the JWT payload
- Providing a professional user experience with elegant session timeout and extension
- Why we chose to split the JWT over two cookies
A high level understanding of JWTs will be helpful in getting the most out of this post. There is an excellent introduction at jwt.io that compliments this post.
Authentication: the Thankless Layer
Logging into an application is a fundamental, crucial, and yet thankless experience. When it works correctly, most users gloss over its underlying complexity. They enter their username and password. Perhaps a piece of secondary authentication such as a code from a text message, TOTP hardware device, or an authenticator app if they have smartly set such a thing up and the service supports it. It is not part of the core product experience - just an expectation. Try talking to your users about salted hashes; they’ll more likely get hungry than think you’re doing a good job of protecting their data.
Authentication has to be secure but it also has to make sense in context of the application; the authenticated experience for Facebook is not the same as for your online banking site. And it shouldn’t be.
Security and Convenience: the Clashing UX Requirements of Authentication
When designing the authentication user experience, convenience and level of security are often at odds.
- Secondary Authentication Method (2FA)
- No persistent login ‘remember me’ (closing the tab/browser logs the user out)
- Inactivity timeout (leaving the tab/window open but not using the site for X amount of time logs the user out)
More convenient for the user
- Username and password only
- Remain logged in across browser sessions
- Remain logged in indefinitely regardless of activity
The Requirements We Chose
Using the Facebook vs. online banking analogy again, Facebook and products like them lean to convenience. They want you in your account, serving you those ads, with as few hurdles as possible.
Your online banking site leans the other way, making darn sure you in fact want to log in and that you don’t stay logged in longer than necessary. We took the same security-centric approach with Lightrail, but with a focus on excellent user experience.
End User Requirements
- Username and password is supplemented with optional 2FA
- Users remain logged in only as long as they have their browser open
- Users only remain logged in only if they have been active within the last 30 minutes.
- If users are inactive for 30 minutes, they are prompted to enter their password and continue their session.
- Users get a different post-login user experience depending on their account type and user permissions.
Security / Best Practice Requirements:
While the user won’t see any difference from these, we noted them as technical necessities for our stateless architecture and desired best practices.
- The front-end authenticates with the RESTful API in the same way as external integrators, meaning via supplying a valid JWT for the user on each request.
- The server does not maintain any session state between calls. There is no stateful middleware layer between the front-end and API.
- Everything HTTPS.
With the requirements in hand, we set out to implement the two fundamental pieces of an authentication system:
- Logging the user in
- Keeping them authenticated between requests
Let’s dive into each.
Logging in the user
The majority of the traditional flow of registration and accepting a username and password remains unchanged and that’s good. If you’re implementing your own code to hash/salt a password or verify it, that’s a problem flag as it should not be architecture dependent.
The difference comes after the credentials are validated. In a traditional web architecture a session gets created — let’s see how that happens and what requirements it solves that we’ll need to solve differently:
What We Can Learn From Traditional Sessions
A successful authentication happy path results in the creation of an in-memory object on the server that persists across requests (the ‘session’). The user’s browser is given an HTTPOnly cookie that points to that session.
For each subsequent request the cookie is sent by the browser. If the session is still present on the server, the user is considered authenticated and the request continues. This flow enables a number of requirements essential to any web application, and that we’ll need for our SPA:
- The username and password only need to be supplied once and not for every request. No brainer.
- If the user is inactive and the server does not get a request for that user for a set amount of time, they are no longer authenticated (as the session will be removed server side) and will be asked to re-login.
- Every time a user is active (meaning makes a request to the server), their session length on the server is renewed.
- If the user closes their browser, they are no longer logged in (as the session-identifying cookie will be gone).
A stateless implementation
As the above shows, the server-side state provides a lot of the desired functionality from our requirements. So how do we make this work without it? This is where JWTs come in. After successful authentication, instead of creating a server-side session, we’ll give the browser a JWT we’ve created for that user. We’ll do this in the form of setting two cookies, each containing a portion of the JWT.
By splitting the JWT across two cookies, rather than using a single cookie or having the JWT stored in the browser local storage, we are able to meet our requirements without adding additional logic. Let’s see how:
Keeping A User Authenticated with the Right UX: The Two Cookie JWT Approach
It is important to understand how the JWT achieves authentication. From jwt.io:
Authentication: This is the most common scenario for using JWT. Once the user is logged in, each subsequent request will include the JWT, allowing the user to access routes, services, and resources that are permitted with that token.
By including the cryptographically-guaranteed unmodified JWT on every request from the front-end, which contains identifying information for that user such as their uuid and allowed permissions, we’ve achieved a continually authenticated experience with no server-side state. Slick.
The Cookie Split
JWTs take the following form:
To implement our requirements, we put the
header.payload in one cookie, and the
signature in another, each with specific cookie attributes.
header.payload content with cookie attributes:
- 30 minute expiry
signature content with cookie attributes:
Over the following sections we’ll see how this split and these attributes enable the discussed requirements.
The Authentication Transformation Layer
To meet our technical requirement of the front-end and external API integrators using the same Lightrail RESTful API, a thin stateless authentication transformation layer is needed.
For external integrators supplying the JWT in the
Authentication header, this layer is a passthrough no-op.
For requests that contain the two cookies (front-end requests), it reconstructs the JWT from the cookie content and places it
Authentication header that the API expects before passing along the request. This layer also guards against CSRF (more on this later), and updates the
header.payload cookie expiry by setting a new cookie on responses that contained valid JWT cookies.
User Roles and the JWT
The concept of different users having different permissions is part of any modern authentication system. For example, a team administrator can add and remove team members, but a standard user cannot.
In our architecture this is accomplished through a
roles list contained in the ‘payload’ section of the JWT.
//other payload data omitted for readability
roles (each is mapped to a list of allowed endpoints on the back end) provide two key functions:
- Calls to the API will fail for any type of request for which the user does not have permission.
- As the
header.payloadcookie, the front-end can make user interface decisions based on a user’s roles.
Important reminder: you can never trust anything client side readable not to be modified. It is worth noting that if using a symmetric signing algorithm (the same key creates and validates) for your JWTs, the client side cannot read the roles securely as it cannot verify the signature (you’d never have your secret key available client side).
In our implementation (we use symmetric signing) we determined that this is not a problem as the decisions the client-side is making are non sensitive (such as routing) and it is not exploitable server-side due to JWT cryptography. The user interface would become broken/unpopulated as any change to the JWT
header.payload cookie content would make the digital signature invalid and all future API calls fail. Users cannot change their
roles or elevate their privileges.
Implementing 2FA is reasonably painless thanks to roles in the JWT. When the user logs in and 2FA is required they are given a JWT with a role that only allows them to supply the secondary piece of authentication and not access any of their normal account functionality. The front-end will see the different role and prompt them for their secondary authentication code.
//other payload data omitted for readability
Upon successful verification of the secondary authentication code, the authentication server will include an updated JWT in the two cookies as part of the success response restoring the user’s normal set of roles. A ‘remember this device’ cookie will also be given so that they are not prompted for 2FA on every subsequent login for that browser for the given expiry period of the cookie (we chose 14 days).
Other Important JWT Payload Content
Beyond roles, the JWT payload contains what is necessary to the authentication process and security. Note that some of the keys and value format are defined by RFC 7519. By following the specification the JWTs remain compatible with JWT client libraries.
- useruuid: fundamental, this ties the token to the particular user that is being authenticated.
- iat: the date/time of issue.
- exp: A date/time after which the server will no longer accept the JWT as valid. We chose 1 day. This is longer than any legitimate continued use of the Lightrail web application and a user will get a new JWT on each login. Note also that a side benefit is that we need no token refresh functionality (as the user re-entering their login credentials serves that purpose)
- jti: giving a token a unique identifier enables optional server-side operations like disabling/blacklisting a token if required.
If you’ve followed everything so far, you’d be rightfully worried about CSRF. The JWT, contained in the two cookies, will be submitted along with every browser request.
So what is stopping a malicious user from reading the API documentation, crafting a URL like https://product.your.com/api/dosomething/private, obfuscating it in a shorturl, and tricking the user to click on it?
Same Site Cookies: a Welcome Improvement to CSRF Protection
As of writing this post, a new type of cookie security attribute called ‘Same Site’ was still in draft. http://www.sjoerdlangkemper.nl/2016/04/14/preventing-csrf-with-samesite-cookie-attribute/ This attribute is specifically designed to prevent CSRF behaviour. SameSite cookies appear to be an ideal additional layer of security for this type of scenario.
Why Not Have the Browser Client Send Back the JWT in the ‘Authorization’ Header Instead of Using Cookies?
This decision is worth mentioning as it is a well documented pattern and we considered it. There are advantages, in that the server-side authentication layer would be simplified to just reading the
Authorization header and CSRF would no longer be a factor.
Recapping Our Requirements
We’ve seen how the JWT across two cookies meets our technical requirements for secure, stateless authentication. Let’s circle back to our user requirements and the rationale behind each cookie and its attributes:
Username and password is supplemented with optional 2FA
Check. JWT role removing/restoring after login and 2FA verification accomplishes this. The front-end can read the JWT role(s) to make routing and display decisions for the user to prompt for 2FA (or not).
Users remain logged in only as long as they have their browser open
signature cookie will be removed on browser close, and therefore the user will no longer be authenticated as the browser won’t have the full JWT to send to the server anymore.
Users only remain logged in only if they have been active within the last 30 minutes
Check. Unless the user takes an action (thereby resulting in an API call and a new
header.payload cookie with a new expiry), their
header.payload cookie will expire and they will be no longer authenticated.
Elegant handling and extension of session timeout
Check. The front-end checks for the existence of the
header.payload cookie as it is not HTTPOnly. If it doesn’t exist, yet other working state remains, the user is prompted for their password. The password submission simply repeats the credentials-for-JWT handshake shown in Figure 2. Upon success, a new
header.payload cookie is issued and the user continues where they left off.
Users get a different post-login user experience depending on their account type and user permissions
header.payload cookie and therefore roles can be read by the front-end and it can make routing and display decisions for the user — all with the safety of knowing that if the user attempts to modify their roles all calls to the server will be rejected.
We’ve detailed a JWT based authentication layer for a stateless single page app that focuses on tight security and also provides an elegant authenticated user experience.