Using JWTs for Authentication in RESTful Applications
The problem
Applications built using the MEAN stack typically use Node, MongoDB and Express on the back end to implement business logic fronted by a RESTful interface. Most of the work is done on the back end, and Angular serves as an enhanced view in the MVC (model-view-controller) pattern. Keeping business rules and logic on the back end means that the application is view-agnostic; switching from Angular to React or straight jQuery or PHP should result in the same functionality.
It’s often the case that we need to protect some back-end routes, making them available only to authenticated users. The challenge is that our back-end services should be stateless, which means that we need a way for front-end code to provide proof of authentication at each request. At the same time, we can’t trust any front-end code, since it is out of our control. We need an irrefutable mechanism for proving authentication that is entirely managed on the back end. We also want the mechanism to be out of the control of the client code, and done in such a way that it would be difficult or impossible to spoof.
The solution
JSON Web Tokens (JWTs) are a good solution for these requirements. The token is basically a JavaScript object in three parts:
- A header that contains information about the algorithms used to generate the token
- A body with one or more claims
- A cryptographic signature based on the header and body
JWTs are formally described in RFC7519. There’s nothing inherently authentication-y about them — they are a mechanism to encapsulate and transmit data between two parties that ensures the integrity of the information. We can leverage this to give clients a way to prove their status without involving the client at all. Here’s the flow:
- Client authenticates with the server (or via a third-party such as an OAuth provider)
- Server creates a signed JWT describing authentication status and authorized roles using a secret that only the server knows
- Server returns JWT to client in a session cookie marked httpOnly
- At each request the client automatically sends the cookie and the enclosed JWT to the server
- Server validates the JWT on each request and decides whether to allow client access to protected resources, returning either the requeseted resource or an error status
Using a cookie to transmit the JWT provides a simple, automated way to pass the token back and forth between the client and the server and also gives the server control over the lifecycle of the cookie. Marking the cookie httpOnly means that it is unavailable to client functions. And, since the token is signed using a secret known only to the server, it is difficult or impossible to spoof the claims in the token.
The implementation discussed in this article uses a simple hash-based signing method. The header and body of the JWT are Base64 encoded, and then the encoded header and body, along with a server-side secret, are hashed to produce a signature. Another option is to use a public/private key pair to sign and verify the JWT. In the example, the JWT is handled only on the server, and so there’s no benefit to using a signing key.
JWT authorization in code
Let’s take a look at some code that implements our workflow. The application that I’m using in the following examples relies on third-party OAuth authentication from Twitter, and minimal profile information is held over for a user from session to session. The Twitter access token returned after a successful authentication is used as a key to a user record in a mongoDB database. The token exists until the user logs out or the user re-authenticates after having closed the browser window (thus invalidating the session cookie containing the JWT). Note that I’ve simplified error handling for readability.
Dependencies
Two convenience packages are used in the following code examples:
- cookie-parser – Express middleware to simplify cookie handling
- jsonwebtoken – abstracts signing and validation of JWTs, based on the node-jws package
I also use Mongoose as a layer on top of mongoDB; it provides ODM via schemas and also several handy query methods.
Creating the JWT and placing in in a session cookie
Once authentication with Twitter completes, Twitter invokes a callback method on the application, passing back an access token and secret, and information about the user such as their Twitter ID and screen name (passed in the results object). Relevant information about the user is stored in a database document:
User.findOneAndUpdate( {twitterID: twitterID}, { twitterID: twitterID, name: results.screen_name, username: results.screen_name, twitterAccessToken: oauth_access_token, twitterAccessTokenSecret: oauth_access_token_secret }, {'upsert': 'true'}, function (err, result) { if (err) { console.log(err) } else { console.log("Updated", results.screen_name, "in database.") } })
The upsert option directs mongoDB to create a document if it not present, otherwise it updates an existing document.
Next, a JWT is assembled. The jsonwebtoken package takes care of creating the header of the JWT, so we just fill in the body with the Twitter access token. It is the access token that we’ll use to find the user in the database during authorization checks.
const jwtPayload = { twitterAccessToken: oauth_access_token }
The JWT is then signed.
const authJwtToken = jwt.sign(jwtPayload, jwtConfig.jwtSecret)
jwtSecret is a string, and can be either a single value used for all users (as it is in this application) or a per-user value, in which case it must be stored along with the user record. A strategy for per-user secrets might be to use the OAuth access token secret returned by Twitter, although it introduces a small risk if the response from Twitter has been intercepted. A concatenation of the Twitter secret and a server secret would be a good option. The secret is used during validation of the signature when authorizing a client’s request. Since it is stored on the server and never shared with the client, it is an effective way to verify that a token presented by a client was in fact signed by the server.
The signed JWT is placed on a cookie. The cookie is marked httpOnly, which restricts visibility on the client, and its expiration time is set to zero, making it a session-only cookie.
const cookieOptions = { httpOnly: true, expires: 0 } res.cookie('twitterAccessJwt', authJwtToken, cookieOptions)
Keep in mind that the cookie isn’t visible to client-side code, so if you need a way to tell the client that the user is authenticated you’ll want to add a flag to another, visible, cookie or otherwise pass data indicating authorization status back to the client.
Why a cookie and a JWT?
We certainly could send the JWT back to the client as an ordinary object, and use the data it contains to drive client-side code. The payload is not encrypted, just Base64 encoded, and would thus be accessible to the client. It could be placed on the session for transport to and from the server, though this would have to be done on each request-response pair, on both the sever and the client, since this kind of session variable is not automatically passed back and forth.
Cookies, on the other hand, are automatically sent with each request and each response without any additional action. As long as the cookie hasn’t expired or been deleted it will accompany each request back to the server. Further, marking the cookie httpOnly hides it from client-side code, reducing the opportunity for it to be tampered with. This particular cookie is only used for authorization, so there’s no need for the client to see it or interact with it.
Authorizing requests
At this point we’ve handed the client an authorization token that has been signed by the server. Each time the client makes a request to the back-end API, the token is passed inside a session cookie. Remember, the server is stateless, and so we need to verify the authenticity of the token on each request. There are two steps in the process:
- Check the signature on the token to prove that the token hasn’t been tampered with
- Verify that the user associated with the token is in our database
- [optionally] Retrieve a set of roles for this user
Simply checking the signature isn’t enough — that just tells us that the information in the token hasn’t been tampered with since it left the server, not that the owner is who they say they are; an attacker might have stolen the cookie or otherwise intercepted it. The second step give us some assurance that the user is valid; the database entry was created inside a Twitter OAuth callback, which means that the user had just authenticated with Twitter. The token itself is in a session cookie, meaning that it is not persisted on the client side (it is held in memory, not on disk) and that has the httpOnly flag set, which limits its visibility on the client.
In Express, we can create a middleware function that validates protected requests. Not all requests need such protection; there might be parts of the application that are open to non-logged-in users. A restricted-access POST request on the URI /db looks like this:
// POST Create a new user (only available to logged-in users) // router.post('/db', checkAuthorization, function (req, res, next) { ... }
In this route, checkAuthorization is a function that validates the JWT sent by the client:
const checkAuthorization = function (req, res, next) { // 1. See if there is a token on the request...if not, reject immediately // const userJWT = req.cookies.twitterAccessJwt if (!userJWT) { res.send(401, 'Invalid or missing authorization token') }
//2. There's a token; see if it is a valid one and retrieve the payload // else { const userJWTPayload = jwt.verify(userJWT, jwtConfig.jwtSecret) if (!userJWTPayload) { //Kill the token since it is invalid // res.clearCookie('twitterAccessJwt') res.send(401, 'Invalid or missing authorization token') } else {
//3. There's a valid token...see if it is one we have in the db as a logged-in user // User.findOne({'twitterAccessToken': userJWTPayload.twitterAccessToken}) .then(function (user) { if (!user) { res.send(401, 'User not currently logged in') } else { console.log('Valid user:', user.name) next() } }) } } }
Assuming that the authorization cookie exists (Step 1), it is then checked for a valid signature using the secret stored on the server (Step 2). jwt.verify returns the JWT payload object if the signature is valid, or null if it is not. A missing or invalid cookie or JWT results in a 401 (Not Authorized) response to the client, and in the case of an invalid JWT the cookie itself is deleted.
If steps 1 and 2 are valid, we check the database to see if we have a record of the access token carried on the JWT, using the Twitter access token as the key. If a record is present it is a good indication that the client is authorized, and the call to next() at the end of Step 3 passes control to the next function in the middleware chain, which is in this case the rest of the POST route.
Logging the user out
If the user explicitly logs out, a back-end route is called to do the work:
//This route logs the user out: //1. Delete the cookie //2. Delete the access key and secret from the user record in mongo // router.get('/logout', checkAuthorization, function (req, res, next) { const userJWT = req.cookies.twitterAccessJwt const userJWTPayload = jwt.verify(userJWT, jwtConfig.jwtSecret) res.clearCookie('twitterAccessJwt') User.findOneAndUpdate({twitterAccessToken: userJWTPayload.twitterAccessToken}, { twitterAccessToken: null, twitterAccessTokenSecret: null }, function (err, result) { if (err) { console.log(err) } else { console.log("Deleted access token for", result.name) } res.render('twitterAccount', {loggedIn: false}) }) })
We again check to see if the user is logged in, since we need the validated contents of the JWT in order to update the user’s database record.
If the user simply closes the browser tab without logging out, the session cookie containing the JWT will be removed on the client. On next access the JWT will not validate in checkAuthorization and the user will be directed to the login page; successful login will update the access token and associated secret in the database.
Comments
In no particular order…
Some services set short expiration times on access tokens, and provide a method to exchange a ‘refresh’ token for a new access token. In that case an extra step would be necessary in order to update the token stored on the session cookie. Since access to third-party services are handled on the server, this would be transparent to the client.
This application only has one role: a logged-in user. For apps that require several roles, they should be stored in the database and retrieved on each request.
An architecture question comes up in relation to checkAuthorization. The question is, who should be responsible for handling an invalid user? In practical terms, should checkAuthorization return a boolean that can be used by each protecte route? Having checkAuthorization handle invalid cases centralizes this behavior, but at the expense of losing flexibility in the routes. I’ve leaned both way on this…an unauthorized user is unauthorized, period, and so it makes sense to handle that function in checkAuthorization; however, there might be a use case in which a route passes back a subset of data for unauthenticated users, or adds an extra bit of information for authorized users. For this particular example the centralized version works fine, but you’ll want to evaluate the approach based on your won use cases.
The routes in this example simply render a Pug template that displays a user’s Twitter accoun information, and a flag (loggedIn) is used to show and hide UI components. A more complex app will need a cleaner way of letting the client know the status of a user.
A gist with sampe code is availabe at gist:bdb91ed5f7d87c5f79a74d3b4d978d3d