JWT Authentication for Single Page Applications
Last updated: April 9, 2021
A set of notes made when I was learning about web authentication. Like with anything else in software development, there are tradeoffs between using sessions vs. JWTs. At the end of this post, I highlight the security concerns that need to be taken into consideration when using JWT authentication.
Traditional client-server interactions were straightforward request-response cycles. Modern Single-Page Application (SPA) interactions can be more complex, and can involve multiple clients and servers.
Traditional server-side authentication
In server-side web applications, the user signs in and their credentials are sent to the server. The server checks these credentials against a database, and if everything matches, a session is created on the server. The session is a piece of data that identifies the user. After the session is created, a cookie gets sent back to the browser with the session_id
of the user. The cookie is saved in localhost and anytime the user makes a request, the cookie is automatically sent along with the request to the server. The server extracts the session_id
from the cookie and verifies that the session exists in the database. This is how users remain authenticated over multiple, separate requests, and is an example of stateful authentication.
What is a session?
In general terms, a session is a way to preserve a desired state. For both server-side and client-side authentication, this piece of state determines whether the user is authenticated.
In a session, this data is stored in memory on the server (or database). For server-side authentication, this is the identification (session_id
) for the user, which is used to make a determination about the user's authentication status. Keeping sessions in this manner is stateful.
In client-side authentication, the SPA has no way to know whether a user is authenticated or not. We can't store a session like the traditional manner, because the SPA is decoupled from the backend.
Client-side authentication
The client-side authentication flow starts off similar to the server-side flow. The user submits their credentials, which are sent to the server and checked against the database. If everything matches, a token is created and signed instead of a session. The token is sent back to the user and saved in the browser, usually in local storage or a cookie. The token is then attached to the Authorization
header on every subsequent HTTP request.
When the request is received on the backend, the token is verified by the secret key stored on the server. The payload is checked and the server looks at the claims on the token. If the token is valid, the requested resource is returned, else a 401 is returned.
RESTful APIs have a formal constraint that they should be stateless, so traditional authentication doesn't conform to those standards. Authentication with tokens can be stateless and is a good approach for authentication with a SPA and RESTful API.
SPAs no longer rely on the server to do authentication. Instead, the client claims to the server that it is authenticated with the token. The backend can now receive requests from multiple clients and it only cares if the token is valid. The backend acts as a decoupled API serving up resources, which means there is no need for additional user access lookups because this information can be included right in the payload.
What's in a JWT?
I've been vague about what a token is so far, so let's get into the details. By token, I am referring to a JSON Web Token.
JWTs are a method for communicating between computers in a secure way, through a JSON payload. A web browser can send a claim (assertions) with a JWT to a server that asserts something about the identity (user). The client says “believe me, and here's the proof”. The token is digitally signed, and contains the proof that this client is who they say they are. All this information exists in the self-contained, compact JWT. This is a great method for performing stateless authentication.
Stateless authentication means that the client and server don't need to know too much about one another. The token contains the necessary information to identify the user, whereas stateful authentication uses sessions to identify users. These sessions must be stored in the database, hence stateful.
This is the structure of a basic JWT and its three main components. The header contains meta information about the token, the payload contains the information you want to pass along, and the signature is what makes the JWT secure. The JWT is hashed with this secret key and makes the token “unchangeable”, in that changes would produce a different hashed JWT.
It's important to note that JWTs are not encrypted, meaning that if someone were to get a hold of a JWT, they could easily decode it and extract the payload from it. This is why you should not put sensitive information in the payload, just enough to identify the user. However, if a JWT is modified, it is immediately invalidated because the hashing algorithm will produce a completely new JWT.
The payload contains claims about the entity for which it was issued. The JWT standard describes a set of reserved claims: its
, sub
, aud
, exp
, nbf
, int
, jti
. You can see above in the picture that sub
is a type of user ID and can be used to identify the user. You can add any arbitrary claim, such as name.
Implementation details about JWT authentication
Because JWT authentication is stateless, the best method for determining the user's authentication status is to go by the JWT's expiry time. If the JWT is expired, it can't be used to access protected resources.
When the user logs in, we provide an application-wide “flag” to indicate the user is logged in, by putting the token in local storage. At any point in the application's lifecycle the token's exp
value can be checked against the current time. An example of a lifecycle event is the route changing. If the token expires, we change the “flag” (remove from local storage) to indicate the user is logged out.
To conditionally render content based on the user's authentication status, we can implement a function to check if the user is authenticated or not. We do this by grabbing the decoded token's expiry date and comparing to the current time. We can then store this isAuthenticated flag on the global state.
User information in the JWT payload
The JWT payload is what makes the JWT useful as it contains a “summary” of the user. We want to use the information in the payload to feed our profile view. A JavaScript library that can decode the JWT payload is jwt-decode
.
Payload best practices
It may be tempting to store a whole profile object in the payload, but we shouldn't do this. It's important to keep the JWT small because it is sent on every single request. Furthermore, because the JWT is decodable, we don't want to store any sensitive information in there.
So, what should be in the payload? Basic user information such as email, name, and picture are good things to keep in the payload that can build a simplified user profile. Consider providing a separate endpoint which retrieves a user profile object if you need more profile data.
Protecting API resources
The whole point of implementing authentication in an app is to restrict resource access to users who own those resources. We can think of the different levels of access:
- Publicly Accessible: Open to anyone
- Limited to Authenticated Users: Open to anyone logged in
- Limited to a Single Authenticated User: Open only to the user logged in
- Limited to a Subset of Authenticated Users: Open to anyone of a specific privilege
JWT middleware
We can create API endpoints that require an authentication check. To pass the check, a valid JWT must be present. If it's valid, access is granted for the resource.
To make this JWT check available for multiple endpoints, we can create a custom middleware that will extract the incoming Bearer
tokens and verify it with the server's secret key. We can also specify the scope
or levels of access for a specific endpoint by using a library like express-jwt-authz
, which will validate against the scope
value from a JWT payload.
Making authenticated requests
Sending authenticated requests from the client to the server requires us to first retrieve the JWT from local storage and attaching it in the Authorization
header. Or, if we choose to store the token in a cookie, it will automatically be attached to every request by the browser.
const token = await getToken();
const response = await fetch('/protected-route', {
headers: {
Authorization: `Bearer ${token}`
}
})
The Bearer
scheme is coming from the OAuth 2.0 specification.
Protecting client-side routes
In your client-side application, you may want to protect certain routes from unauthenticated users. In traditional web apps, the server can verify the user's session before serving up the requested page. In SPAs, however, we don't have a server protecting our routes and the entire client-side is bundled and loaded on the first visit. We can protect API resources using the JWT check, but how will we protect client-side routes?
Protecting client-side routes can be tricky because the user can modify the expiration time or scope in their JWT. Moreover, we can't verify the JWT on the client-side because the secret only lives on the server.
Does it matter if the user accesses protected client-side routes? In the end, even if a user hacks their way into the protected route, they will not receive any information because protected resources live on the server. The server is responsible for feeding data into the client view, so as long as sensitive information is stored correctly on the server, there is no issue with a simple check on the client-side.
Given what we discussed above, we can safely use the expiration time and scope claims from the JWT to decide what to render to the user. We can build out a PrivateRoute
component to wrap routes we want to protect.
Important considerations
Nothing is 100% secure, and JWTs are no exception. There are several attack vectors on JWT authentication. If you store your token in local storage, you are vulnerable to cross-site scripting (XSS). If you do store your token in local storage, you should be protecting your site against XSS by ensuring you are following protocols. If you store your token in a cookie, you are vulnerable to cross-site request forgery (CSRF). Cookies can be set as httpOnly
to prevent JavaScript access. It is commonly recommended to store the token in a httpOnly
cookie.
There are also man-in-the-middle attacks (MITM), which is prevalent on public networks. Always serve your application and API over HTTPS.
Because JWTs should be short-lived (around 1h), we need to implement refresh tokens to provide a better user experience. A refresh token generates new tokens for the user when their tokens expire or are about to expire. These refresh tokens are long-lived and stored on the server. They should be kept secret and never stored on the client.