[logo] a small computer

Access & Refresh Tokens - A Deep Dive into the JWT Authentication Flow By Building an Authentication System with NodeJS & Redis

Access & Refresh Tokens - A Deep Dive into the JWT Authentication Flow By Building an Authentication System with NodeJS & Redis

Bradley Kofi
Published 30/04/2022.
12 minute read
logo of undefined

Content Length Warning.

This guide is long!

It took a long time to write and so, will probably take a long time to read. Feel free to skip to the relevant sections you're interested in, or bookmark ( + ) (every header is a link) this page and come back later.

Table of Contents

Goals

By the end of this article, the reader should:

  • Understand the fundamentals of JWT authentication.
    • Access & Refresh Tokens
  • Understand the downsides of JWT authentication.
    • Token revocation.
    • Token storage.
  • Have solutions to problems presented by JWT.

Introduction

JWT (pronounced "jot") is the de-facto authentication mechanism for the web. Its inner workings are relatively easy to understand, and it has such widespread adoption that virtually every programming language has a well-supported JWT library. It enjoys this popularity because it solves a lot of problems encountered with using cookies and sessions for authentication.

However, JWT comes with its own set of drawbacks.

This guide aims to help the reader understand how JWT authentication works, its pitfalls and how to overcome them. Knowledge of the underlying technologies (specifically HapiJS) isn't required. Remember, the point isn't frameworks, it's concepts 😉

What is JWT? What Makes it so Great?

JWT stands for JSON Web Token, and it both refers to the RFC 7519 JSON Web Token Spec and an authentication mechanism that facilitates stateless access to server-side resources.

Okay, new word - stateless. What does it mean?

Stateless

Of a system or protocol, such that it does not keep a persistent state between transactions.

  • A stateless server treats each request independently.

Wiktionary

Statelessness refers to the fact that the server doesn't have to maintain any “state” (data in memory, or in state) in order to identify a user across multiple requests.

To help wrap your head around it, let's take a step back.

Cookies and Sessions - A Tale of Statefulness

The “stateful” way to identify authenticated users is to use cookies and sessions.

When a user logs in, the server generates a random unique token and returns it to the client device, initiating a session. The client then stores it in their client-side storage mechanism of choice.

With every subsequent request to the server, the client should also send the token. As long as the client has access to this token, it can maintain the session, and the server can reliably identify the logged-in device and access its associated roles and privileges.

However, a unique token on its own doesn't tell us anything about the user. In order to get metadata associated with the token, the API/server has to make a query to the database. This happens with every request.

This can get problematic very quickly as an application starts to receive more traffic. The increased number of queries necessitates a caching layer of some kind, for instance, an in-memory database. Otherwise, your users will be forced to deal with slow load times.

Notice how the API has to fetch the user's information from a database? And how, to make the app scalable, we have to store the user's information in-memory? That's referred to as maintaining "state" on the user, and thus, such a server or API is said to be stateful.

That said, cookies enjoy widespread usage today because of their relative simplicity over JWTs. Reasons to pick cookies and sessions over JWT today include:

  • Cookies don't need a lot of complex logic to get running. They work out of the box.
  • Sessions are easy to revoke. As a result, implementing "Sign Out on All Devices" functionality is pretty straightforward, for example.

Downsides of Cookies & Sessions

The main reason to avoid cookies and sessions today is because of the aforementioned statefulness.

  • Stateful servers are tedious to set up. This may include specific database and server configurations that ensure queries are fast. If not properly configured, the API might end up being incredibly slow.

A Worthy Contender: JWT - The Very Basics

The rise of Single Page Applications (SPAs) and increasing popularity of REST APIs heralded the decline of cookies and sessions for authentication. In large part, that was because of the shiny new kid on the block - JWT.

First introduced in the RFC 7519 JSON Web Token spec, JWTs became widely accepted due to their compactness, relative ease of use across different platforms, and, most importantly, the fact that they could carry a payload.

A JWT (the token, not the spec 😉 ) consists of three parts: the header, claims (also called the "payload") and signature. These parts respectively contain:

  • Information about the type of token and its signing algorithm
  • Any data that can be represented as JSON (typically smaller than 5MB in size), and,
  • A way to verify that the token is authentic.

Each of the three parts is a simple json object. They are encoded using Base64 and concatenated with a "." to form the final JWT string. Therefore, a JWT token usually looks like: header.payload.signature

Example of a JWT token

Despite the redundancy, don't mind my referring to a JWT as a "JWT Token."

This is only a brief introduction to JWT to keep this guide short. For a more in-depth view of the inner workings of JWT, take a look at Introduction to JSON Web Tokens by jwt.io.

The Downsides of JWT

While JWT does indeed solve a lot of problems, it also comes with its own set of drawbacks. In particular:

  • JWT tokens are difficult to revoke. Since the server keeps no state, and does not store access tokens (nor should it!) there is no central mechanism to "disable" a compromised token.

  • JWT tokens are difficult to store in web clients. localstorage is great, but it’s vulnerable to XSS attacks. Cookies are also vulnerable to XSS attacks, but we can mark them as “secure” to make them inaccessible using Javascript. This, however, also makes the token inaccessible to you via Javascript. It's impossible to decode the token.

    It's all about compromises. To better understand, the pros and cons of each, check out this blog post by Michelle Wirantono

Four Rules for Efficient JWT Use

There are a few basic rules you should always follow in order to overcome the downsides of JWT:

  • Don’t store sensitive information inside a JWT token.

    Remember, a JWT token contains encoded values, not encrypted (at least not by default.)

    What's the difference? I hear you ask.

    Encrypted

    Transformed in such a way that only "certain individuals" can reverse.

    Popular encryption algorithms include Rivest-Shamir-Adleman (RSA) and Pretty Good Privacy (PGP).

    Encoded

    Transformed in such a way that the transformation is easy to reverse

    Commonly-used encodings include Base64 and Unicode.

    Encryption is generally used to ensure data can only be read by certain parties, while encoding is normally used to ensure data usability, e.g., to prevent data modification during transport.

    A JWT token is easily reversible. Keep any sensitive information (emails, passwords, and other personally-identifiable information) out of the token. If absolutely necessary, encrypt the token using a private key and share the public key with the client.

  • Ensure JWT tokens are short-lived

    An access token should expire after 20-30 minutes. If an attacker were to get their hands on the access token, it would only be useful for a short window of time.

    Once a token has expired, it should be “refreshed”. The client provides a valid “refresh token”, and the API creates a new access token and returns it to the client.

  • Think about where to store your tokens!

    On the browser, access tokens can be stored inside localStorage or cookies. There’s a lot of mixed advice on the interwebs regarding which is a better storage medium for access tokens.

    Detractors of localStorage argue against it because it’s accessible through Javascript, and thus, potential XSS attacks. They argue for using cookies with the isHttpOnly flag. The downside is that your will no longer be able to access the token through Javascript. You won’t be able to decode the token and access information in the payload.

    Alternatively, store your access tokens in localStorage and use a isHttpOnly cookie for the refresh token.

  • Store a JWT identifier to make tokens revocable.

    A fully stateless authentication system is a bit of a pipe dream. In order to ensure our tokens are revocable, we should store an identifier for every JWT token that is generated by the system.

Building a (Sort of) Stateless JWT API from Scratch

Having covered the basics, let's proceed to build a JWT authentication system with refresh tokens and revocable access tokens from scratch. This system is only going to be sort of stateless because we will store some metadata about logged-in users and tokens in order to make them revocable.

While this system is "technically" stateful, the server stores state about the authorized tokens, not the authorized user. It's a small difference, but a big deal.

First, let's layout the requirements.

Requirements

We need:

  • A REST API endpoint where clients can provide a payload containing a username and password.
  • A REST API endpoint where clients can provide a refresh token and receive a new access token.
  • A REST API endpoint where from where a user's refresh tokens can be revoked.

Psst... Three Layer Architecture. Heard of It?

The code below relies extensively on understanding three-layer architecture. In short, we use:

  • Database Access Objects (DAOs)/Models to run queries
  • Services to write business logic, and,
  • Controllers to write API-related code (validators, event emitters, etc). They "control" the flow of data between clients and server-side resources.

PS: I used a separate layer for routes. This isn't strictly necessary.

The /login Endpoint

For the first requirement, we need a route where the client can send a POST request (/accounts/login). If successful, it will receive a

200 (Success) response, together with a payload containing an access and refresh token.

The request payload is a JSON object with username and password keys:

{ "username": "awesomedude69@litmail.com", "password": "superstrongunguessablepassword69" }

Let's begin by defining the validation schema and route:

accounts.routes.ts
import {Server} from "@hapi/hapi" import Joi from "joi" import {accountsController} from "@controllers" export const UserLoginSchema = Joi.object({ login: Joi.string().required(), password: Joi.string().min(8).required() }) export default (server: Server) => { server.route({ path: '/accounts/login', method: 'POST', // todo: add response validation type & schema options: { auth: false, // all routes require authentication by default validate: { payload: UserLoginSchema, // the validation schema }, }, handler: async request => await accountsController.loginUser({ payload: request.payload }), }); }

Inside the controller - AccountsController#loginUser(payload) - we simply call the service method - AccountsService#loginUser(payload) - and respond to the Result.

If the Result is success-ful, the API emits an event (ON_LOGIN_SUCCESSFUL) and returns a response body containing an access and refresh token.

If the result is fail-ed or error-ed, it returns a 400 (Client Error), or a 500 (Server Error) respectively.

accounts.controller.ts
import {Request} from '@hapi/hapi'; import {authService} from '@services'; import {responseHelper} from '@helpers'; import events from "@events" type UserLoginRequestPayload = { username: string, password: string, } class AccountsController { async loginUser(request: Request) { const payload = request.payload as UserLoginRequestPayload; // attempt to log in a user const loginUserResult = await authService.loginUser(payload) switch (loginUserResult.status){ case 'success': const {tokens, user} = loginUserResult.data; // when login succeeds, emit an "ON_LOGIN_SUCCESSFUL" event together with the generated tokens request.server.events.emit(events.accounts.ON_LOGIN_SUCCESSFUL, tokens); return { accessToken: tokens.accessToken.value, refreshToken: tokens.refreshToken.value, } } // if a login fails or some other error is encountered // return their respective error messages and codes. return responseHelper.returnGenericResponses(loginUserResult); } }

We don't want to have fat controllers, so we won't include any business logic within this class. Instead, we rely on services, publishers and listeners. A fat controller is one that does too much work.

All the magic (the so-called "business logic") happens inside AuthenticationService.

First, the API checks whether the provided username exists in the database.

authentication.service.ts
import {userDAO} from "@models" class AuthService { async loginUser(payload) { // find the user by their email/username/phone number const user = await userDAO.findUserByUsername(payload.username); // ... } }

If the username exists, it then attempts to verify that the password in the request payload matches a hash saved on the same row as the username in the database.

If the password is correct, it returns a 200 (Success) response code with access and refresh tokens in the response body. If not, it returns a 400 (Client Error) code instead.

import {userDAO} from "@models" import {userHelper, jwtHelper} from "@helpers" import {messages} from "@constants" class AuthService { async loginUser(payload) { // find the user by their email/username/phone number const user = await userDAO.findUserByLogin(payload.login); // if the user has an account if (user) { const {uuid, password, is2FAEnabled, isVerified, role} = user.account; // check if the password they provided is correct const isPasswordCorrect = await userHelper.isPasswordCorrect({ passwordToVerify: payload.password, bcryptPasswordHash: password, }); // if the password is correct if (isPasswordCorrect) { // check if MFA (Multi-Factor Autnentication) is enabled if (is2FAEnabled) { // if enabled don't return accessToken and refreshToken just yet // run MFA logic first. } else { // if they don't have MFA enabled, generate the tokens // We'll cover how .generateTokens() works in just a bit. ;) const {accessToken, refreshToken} = jwtHelper.generateTokens({ isVerified: isVerified, role: role.name, userId: uuid }); // and return them to the client. return { accessToken: accessToken.value, refreshToken: refreshToken.value } } } else { // if password is incorrect. // return a 400 (Client Error) code // (maybe lock account on too many wrong password requests?) return { status: 'fail', type: "CLIENT_ERROR", message: messages.generic.LOGIN_ERROR, }; } } else { // if the account doesn't exist, // return a 400 (Client Error) code } } }

If the username doesn't exist, the API returns a 400 (Client Error) code.

import {userDAO} from "@models" import {messages} from "@constants" class AuthService { async loginUser(payload) { // find the user by their email/username/phone number const user = await userDAO.findUserByLogin(payload.login); // if the user exists if (user) { // ... } else { // if the user doesn't exist, return a 400 (Client Error) code. return { status: 'fail', type: "CLIENT_ERROR", message: messages.generic.LOGIN_ERROR }; } } }

📝 Hey! Look at me! I'm a note!

The same message is sent when a password is incorrect, as when an account doesn't exist (.LOGIN_ERROR).

A discerning attacker could notice that different messages are sent depending on whether a username exits in the database or not. Brute-forcing into an account they know exists is much easier than guessing both the username and password.

See the OWASP REST API Cheat Sheet for more information.

Generating and Validating Access & Refresh Tokens

In this section, we are going to explore how to actually generate access and refresh tokens. But first, let's take a detour and talk about token claims.

Token Claims

The token claims is a JSON object that is encoded and stored inside a JWT string. Here is the complete claims object for our access tokens:

{ "userId": "6a034d6f-f9bd-442c-afe9-888cd60760c1", "role": "ROLE_USER", "isVerified": true, "tokenType": "access", "refreshTokenId": "443d8502-eddb-4138-bee2-7a46cb5541d5", "iss": "api.retrobie.com", "aud": ["retrobie.com"], "iat": 1516239022, "exp": 1516239022, "jti": "86dc2e6d-d964-4022-915b-9bd2ec01df14" }

A rundown of each of these keys is as follows:

  • userId: clients can only access the API with tokens generated for them.
  • role: certain actions can only be performed by users with the ROLE_ADMIN role.
  • isVerified: certain actions can only be performed by users with verified accounts.
  • tokenType: used to distinguish between different types of tokens that we will generate.
  • refreshTokenId: helps us to identify the refresh token that the access token was created alongside.

The rest are registered claim names. They are declared in the JWT spec and serve as a good starting point for useful, interoperable claims. These are normally generated automatically by JWT libraries and cannot be re-declared by an application.

Their roles are:

  • iss: The issuer. Who issued this token?
  • aud: The audience. Who is this token intended for?
  • iat: When the token was issued.
  • exp: When the token will expire.
  • jti: A unique identifier for the token.

And the claims for our refresh tokens (excluding registered claim names):

{ "userId": "6a034d6f-f9bd-442c-afe9-888cd60760c1", "tokenType": "refresh", "jti": "443d8502-eddb-4138-bee2-7a46cb5541d5" }

Generating Refresh Tokens

Let's start off by generating a refresh token. It will have a lifetime of 28 days and have a unique id (jti).

jwt.helper.ts
import ms from "ms" import jwt from 'jsonwebtoken'; import env from "@helpers" type Role = 'ROLE_ADMIN' | 'ROLE_USER' // shouldn't be static values in a real application interface AccessTokenUserPayload { // the "user" part of our access tokens isVerified: boolean; role: Role; username: string; userId: number; } class JWTHelper { protected generateRefreshToken(payload: AccessTokenUserPayload) { // include the necessary data in the token payload const refreshTokenPayload = { userId: payload.userId, tokenType: 'refresh', }; // generate a unique identifier for this refresh token const jti = uuidV4(); // create the refresh token const refreshToken = jwt.sign(refreshTokenPayload, env.get('JWT_SECRET'), { expiresIn: String(ms("28d")), // expires in 28 days issuer: 'api.retrobie.com', audience: ['mobile.retrobie.com', 'retrobie.com'], jwtid: jti, }); return { value: refreshToken, jti, } } }

After 28 days, the user's "session" expires, and they have to log into our system agaom.

🔌 Shameless Plug.

env.get() is a helper class for retrieving environment variables from multiple environments.

I wrote a blog post about it. Check it out? 🥺👉👈

Generating Access Tokens

Next, let’s generate a signed access token.

The process is mostly the same as generating a refresh token, save for the fact that every access token requires a refreshTokenId. In other words, every access token has to be linked to one refresh token.

jwt.helper.ts
import jwt from "jsonwebtoken" import uuidV4 from "uuid/v4" import {AccessTokenPayload} from "@types" import ms from "ms" import {env} from "@helpers" class JWTHelper { // ... public generateAccessToken(payload: AccessTokenPayload, refreshTokenId: string) { // used to revoke individual tokens const jti = uuidV4(); // put together the access token payload const accessTokenPayload: AccessTokenPayload = { ...payload, refreshTokenId, tokenType: 'access', }; // and sign the token with our secret key. const accessToken = jwt.sign(accessTokenPayload, env.getVariable('JWT_SECRET'), { expiresIn: String(ms("30m")), // 30 min issuer: 'api.thekenyandev.com', audience: ['mobile.thekenyandve.com', 'thekenyandev.com'], jwtid: jti }); return { token: accessToken, jti }; } }

👥 A Relationship... Of Sorts

At this point, it's worth mentioning that an access token can belong to just one refresh token at a time. However, a refresh token can have many to a many access token linked to it at once. This creates a 1-n (one-to-many) relationship between refresh tokens and access tokens.

Then, we can condense both of these functions into a single method:

jwt.helper.ts
import {AccessTokenUserPayload} from "@types" type FreshTokens = { accessToken: { value: string; jti: string; }, refreshToken: { value: string; jti: string; } } class JWTHelper { //... generateTokens(payload: AccessTokenUserPayload): FreshTokens { const refreshToken = this.generateRefreshToken(payload); const accessToken = this.generateAccessToken(payload, refreshToken.jti); return { accessToken, refreshToken }; } }

Voilà! Our API can now generate a set access and refresh tokens.

Next, we need to make sure tokens sent to our server are genuine.

Validating Access Tokens

Our API still needs a way to validate the access tokens. We need to ensure they have the correct signature, haven't expired, etc.

To do this, we start by registering an authentication strategy in Hapi.

jwt.plugin.ts
import hapiJwt from 'hapi-auth-jwt2'; import {jwtHelper, env} from '@helpers'; import {Server} from "@hapi/hapi" import Boom from "@hapi/boom" import {messages} from "@constants" const customErrorFunc = e => { throw Boom.unauthorized(messages.http.UNAUTHORIZED); }; const JWTPlugin = { name: 'JwtPlugin', async register(server: Server) { server.auth.strategy('jwt', 'jwt', { key: env.get('JWT_SECRET'), validate: jwtHelper.validateToken, errorFunc: customErrorFunc, // this error is thrown for every invalid token. tokenType: 'Bearer' }); // require jwt for all routes server.auth.default('jwt'); } } export default JWTPlugin

jwtHelper.validateToken is called for every request with the Authorization header.

It returns an {isValid: boolen} object depending on whether the token is valid or not. The checks we include here take place on top of other validation performed by the library itself. We don't need to check whether the token has expired, or whether the signature is valid, for instance, because the library already does that for us.

For now, all we need to do is check whether the provided token has a key tokenType set to access

jwt.helper.ts
import {AccessTokenPayload} from "@types" class JWTHelper { async validateToken(decoded: AccessTokenPayload, request: Request) { if (decoded.tokenType === 'access') { return { isValid: true } } // invalid jwt. Only access tokens allowed. return { isValid: false }; } }

Refreshing Expired Access Tokens

Onto the second requirement.

Once an access token has expired, it will no longer pass validation, and thus won't be allowed to access protected resources. We need a route where clients can send an expired token together with a refresh token and receive a new access token in return.

Here's what it looks like:

auth.routes.ts
import Server from "@hapi/hapi" import {authController} from "@controllers" import Joi from "joi" const TokenRefreshRequestSchema = Joi.object({ expiredToken: Joi.string().regex(/^[\w-]*\.[\w-]*\.[\w-]*$/).required() }) export default (server: Server) => { server.route({ path: '/auth/session/refresh', method: 'POST', options: { auth: false, state: { parse: true, // parse cookies failAction: 'error' // fail if no cookies are passed }, validate: { payload: TokenRefreshRequestSchema, }, }, handler: async req => await authController.refreshAccessToken(req) }); }

The refresh token should be passed as a secure (in production), HttpOnly cookie. As long as the correct path is defined in the cookie, the browser will automatically send it together with any requests made to that endpoint`.

I expect you have some idea what the controller is going to look like, so let's skip down to AuthService and return to the controller later.

We first decode the refresh token and fetch its state from Redis.

If it's present in Redis and is active, we then check if the refresh token has the same id as the refreshTokenId field in the decoded access token.

auth.service.ts
import {Server} from "@hapi/hapi" import {jwtHelper} from "@helpers" class AuthService { // ... async refreshAccessToken(server: Server, {expiredToken, refreshToken}) { // decode the refresh token const refreshTokenPayload = jwtHelper.decode(refreshToken) // and get its state from Redis const refreshTokenState = await server.methods.getRefreshTokenFromBucket(refreshTokenPayload.jti) // decode the access token const oldAccessTokenPayload = jwtHelper.decode(expiredToken, { ignoreExpiry: true, }); if ( refreshTokenState?.isActive && // if the refresh token is active oldAccessTokenPayload.refreshTokenId == refreshTokenPayload.jti // and the expired token jti matches the refresh token jti ) { // .. } return { status: "fail", message: "Invalid token", }; } }

If so, we generate a new access token using information from the old token and pass the token to the client.

auth.service.ts
import {Server} from "@hapi/hapi" class AuthService { // ... async refreshAccessToken(server: Server, {expiredToken, refreshToken}) { // decode the refresh token const refreshTokenPayload = jwtHelper.decode(refreshToken) as RefreshTokenPayload // and get its state from Redis const refreshTokenState = await server.methods.getRefreshTokenFromBucket(refreshTokenPayload.jti) as TokenRedisState // decode the access token const oldAccessTokenPayload = jwtHelper.decode(payload.expiredToken, { ignoreExpiry: true, }) as AccessTokenPayload; if ( refreshTokenState?.isActive && // if the refresh token is active oldAccessTokenPayload.refreshTokenId == refreshTokenPayload.jti // and the expired token refreshTokenId matches the refresh token jti ) { // create a new token payload const newAccessTokenPayload: AccessTokenUserPayload = { userId: oldAccessTokenPayload.userId, role: oldAccessTokenPayload.role, isVerified: oldAccessTokenPayload.isVerified, username: oldAccessTokenPayload.username, } // generate a new access token const newAccessToken = jwtHelper.generateAccessToken(newAccessTokenPayload, refreshTokenPayload.jti!); // and return it return { status: "success", data: newAccessToken }; } return { status: "fail", message: "Invalid token", }; }

Growing Pains - Adjusting the /login Endpoint

We need to modify our login business logic to accommodate these recent changes.

Let's head into the controller and set a cookie for every successful login:

auth.controller.ts
import {Request, ResponseToolkit, Server} from '@hapi/hapi'; import constants from '@constants'; import {UserLoginRequestPayload } from '@types'; import {jwtHelper, responseHelper} from '@helpers'; import {authService} from '@services'; class AuthController { async loginUser(request: Request, h: ResponseToolkit) { const loginUserResult = await authService.loginUser(request.payload as UserLoginRequestPayload) switch (loginUserResult.status){ case 'success': const {tokens, user} = loginUserResult.data; await request.server.events.emit(constants.events.authentication.ON_LOGIN_SUCCESSFUL, tokens); h.state("x-refresh-token", tokens.refreshToken.value, { // the cookie will be sent automatically to the server // any time this route is called path: "/api/v1/auth/session/refresh", ttl: jwtHelper.getExpiryDuration("refresh_token"), isSecure: false // only in development! }) return h.response({ accessToken: tokens.accessToken.value, refreshToken: tokens.refreshToken.value, }); } return responseHelper.returnGenericResponses(loginUserResult); } }

Great! It works!

screenshot

Redis - The Secret Weapon

Why Redis?

Redis is an excellent, performant in-memory database, and the perfect candidate for a use-case such as this. We use it to avoid repeated requests to our database. Other in-memory databases work fine, too, of course.

When a user logs in, here's what the Redis bucket looks like:

screenshot

Once an access token expires, Redis drops it automatically. Here is what the Redis bucket looks like when the access token expires:

screenshot `

The access_tokens bucket was dropped because there were no more keys in it.

The trick is to make sure the ttl set on the Redis entry is the same as the expiry duration for your access tokens. We can reasonably assume access tokens will be dropped from Redis within milliseconds of expiry.

The same goes for refresh tokens. Only, since it can't be refreshed, the user has to log in again to get a new refresh token.

Making Access & Refresh Tokens Revocable

Theory

Since we have a unique id for every generated token, we can track them individually, and now have a way to disable rogue access tokens.

Once a client logs in successfully, the API stores the refresh token in a Redis bucket, indexed by its jti, together with metadata for the token's status.

Since Redis stores data in key-value pairs, we can visualize the resulting data structure like so:

refresh-token-redis-bucket.json
{ "443d8502-eddb-4138-bee2-7a46cb5541d5": { "isActive": true }, "1811bcd1-f7b0-40aa-9517-85dbf89d43d0": { "isActive": false // this refresh token has been disabled. }, "d6383aaf-e465-4957-aa62-2e57bacf4327": { "isActive": true } }

Access tokens are indexed by their jti and stored in a different Redis bucket. However, the data structure looks the same:

access-token-redis-bucket.json
{ "86dc2e6d-d964-4022-915b-9bd2ec01df14": { "isActive": true }, "611b648a-d2e7-4204-9f3b-a92360dafa0e": { "isActive": false // this access token has been disabled. }, "7095d046-20a6-4ebf-9273-72c13bc98e59": { "isActive": true } }

Now, whenever we have an access token, we can get its refresh token using the refreshTokenId key in the jwt claims.

Notice, however, that the reverse operation isn't possible. With a refresh token, it's impossible to get all related access tokens. While this is possible to implement, it just never seemed particularly useful to relate one refresh token with multiple access tokens (by extension, clients) for my application. I don't know the implications of this just yet, but I think it's interesting to point out.

Lastly, we need a bucket that will help us keep track of which accounts generated refresh tokens belong to. This will simply contain a list of tokens.

users-redis-bucket.json
{ "tokens": [ "443d8502-eddb-4138-bee2-7a46cb5541d5" ] }

Alright Already. Just Show Me the Code. 🙁

We first need to register the .LOGIN_SUCCESSFUL with Hapi:

events.plugin.ts
import {Server} from "@hapi/hapi" import events from "@events" import {eventListener} from '@listeners'; const EventHandlerPlugin = { name: 'EventHandlerPlugin', register: async function (server: Server) { // register the event server.event(events.accounts.ON_LOGIN_SUCCESSFUL); // register the event listener server.events.on(events.accounts.ON_LOGIN_SUCCESSFUL, (payload) => eventListener.accounts.onLoginSuccessful({server, payload}), ); } } export default EventHandlerPlugin;

Inside our listener, we need to ensure both the access and refresh token ids are stored in a Redis Bucket:

accounts.listener.ts
import {jwtHelper} from "@helpers" import {FreshTokensWithUser} from "@types" import {Server} from "@hapi/hapi" class AccountsListener { async onLoginSuccessful(server: Server, payload: FreshTokensWithUser){ try { await jwtHelper.addTokensToBucket(server, payload); } catch (e) { console.error(e) } } }

JwtHelper does all the work by calling the respective methods needed to store our token ids.

jwt.helper.ts
import {Server} from '@hapi/hapi'; class JwtHelper { async addTokensToBucket(server: Server, tokens) { await this.addAccessTokenToBucket(server, tokens.accessToken) await this.addRefreshTokenToBucket(server, tokens.refreshToken) server.methods.setLoggedInUserToBucket({ userId: tokens.userId, refreshTokenId: tokens.refreshToken.jti }); } }

Here is how we declare the three Redis buckets we need:

jwt.plugin.ts
import {Server} from "@hapi/hapi"; import {jwtHelper} from "@helpers" const JWTPlugin = { name: 'JwtPlugin', async register(server: Server) { const accessTokenRedisBucket = server.cache({ cache: 'retrobie_cache', expiresIn: jwtHelper.getExpiryDuration('access_token'), segment: 'access_tokens', }); const refreshTokenRedisBucket = server.cache({ cache: 'retrobie_cache', expiresIn: jwtHelper.getExpiryDuration('refresh_token'), segment: 'refresh_tokens', }); const usersRedisBucket = server.cache({ cache: 'retrobie_cache', expiresIn: jwtHelper.getExpiryDuration('refresh_token'), segment: 'users', }); } }

Then, to register methods that set the tokens, and the user to their respective Redis buckets:

jwt.plugin.ts
import {Server} from "@hapi/hapi"; import {jwtHelper} from "@helpers" const JWTPlugin = { name: 'JwtPlugin', async register(server: Server) { const accessTokenRedisBucket = server.cache({ cache: 'retrobie_cache', expiresIn: jwtHelper.getExpiryDuration('access_token'), segment: 'access_token', }); const refreshTokenRedisBucket = server.cache({ cache: 'retrobie_cache', expiresIn: jwtHelper.getExpiryDuration('refresh_token'), segment: 'refresh_token', }); const usersRedisBucket = server.cache({ cache: 'retrobie_cache', expiresIn: jwtHelper.getExpiryDuration('refresh_token'), segment: 'users', }); server.method('addAccessTokenToBucket', async (payload) => await accessTokenRedisBucket.set( payload.accessToken.jti, {isActive: true} ); ); server.method('addRefreshTokenToBucket', async (payload) => await refreshTokenRedisBucket.set( payload.refreshToken.jti, {isActive: true} ) ); server.method('setLoggedInUserToBucket', ({userId, refreshTokenId}) => { usersRedisBucket.set<UserRedisBucket>(String(userId), { tokens: [refreshTokenId] }); } ); } }

We also need methods to help us read from Redis:

jwt.plugin.ts
import {Server} from "@hapi/hapi"; import {jwtHelper} from "@helpers" const JWTPlugin = { name: 'JwtPlugin', async register(server: Server) { const accessTokenRedisBucket = server.cache({ cache: 'retrobie_cache', expiresIn: jwtHelper.getExpiryDuration('access_token'), segment: 'access_token', }); const refreshTokenRedisBucket = server.cache({ cache: 'retrobie_cache', expiresIn: jwtHelper.getExpiryDuration('refresh_token'), segment: 'refresh_token', }); const usersRedisBucket = server.cache({ cache: 'retrobie_cache', expiresIn: jwtHelper.getExpiryDuration('refresh_token'), segment: 'users', }); server.method('addAccessTokenToBucket', async (payload) => await accessTokenRedisBucket.set( payload.accessToken.jti, {isActive: true} ) ); server.method('addRefreshTokenToBucket', async (payload) => await refreshTokenRedisBucket.set( payload.refreshToken.jti, {isActive: true} ) ); server.method('getAccessTokenFromBucket', async (jti) => await accessTokenRedisBucket.get(jti) server.method('getRefreshTokenFromBucket', async (jti) => await refreshTokenRedisBucket.get(jti} server.method('setLoggedInUserToBucket', ({userId, refreshTokenId}) => { usersRedisBucket.set(String(userId), { tokens: [refreshTokenId] }); } ); } }

Remember our humble JWTHelper class? It's time for a makeover!

jwt.helper.ts
class JWTHelper { async validateToken(decoded: AccessTokenPayload, request: Request) { const context = 'JWTHelper.validateAccessToken()'; if (decoded.tokenType === 'access') { // Look for the refresh token inside redis. const refreshTokenActiveState = await request.server.methods.getRefreshTokenFromBucket( decoded.refreshTokenId, ); // if the refresh token exists and it is active if (refreshTokenActiveState?.isActive) { // check the access token status on Redis. const accessTokenActiveState = await request.server.methods.getAccessTokenFromBucket( decoded.jti, ); // if found, return its state return { isValid: accessTokenActiveState.isActive }; } // token not found or inactive return { isValid: false } } // invalid jwt. Only access tokens allowed. return { isValid: false }; } }

On top of checking if the provided token has the type access, it also checks if the refresh token exists and its active. If not, the request is rejected with the {isValid: false} object, and, afterwards, a 401 (Forbidden) response.

Why a 401 (Forbidden) Response? (Why not 403 (Unauthorized)?)

Despite their apparent similarities, the two response codes mean two entirely different things.

401 Forbidden

If the request already included Authorization credentials, then the 401 response indicates that authorization has been refused for those credentials.

- Oded on StackOverflow
403 Unauthorized

The server understood the request, but is refusing to fulfill it.

- Oded on StackOverflow

Revoking Access & Refresh Tokens

What else is left to do but see our project in action? 🤩

Let's imagine a hypothetical scenario where we realize a certain account is displaying "odd" behaviour. Maybe it's logging in from strange IP addresses, or it's been trying to access resources without proper permissions. It might even be that our super-duper complex AI algorithm has flagged its activity as suspicious. It doesn't matter. Once a compromise is realized, we need to revoke all of that account's refresh tokens to lock the attacker out.

Let's start by creating a route (/security/users/{id}/tokens/refresh/revoke) that will be responsible for just that.

This guide will only implement revocation for refresh tokens, because revoking a refresh token will also effectively revoke any dependent access tokens. If the refresh tokens don't work, access tokens won't have access to our system either. We're killing two birds with one stone.

auth.routes.ts
import {Server} from "@hapi/hapi" import {authController} from "@controllers" export default (server: Server)=> { server.route({ path: "/security/users/{id}/tokens/refresh/revoke", method: "POST", handler: async (req: Request) => await authController.revokeTokensForUser(req) }); }

Then, we need a way to fetch all of a particular user's refresh tokens, and another way to drop or deactivate them.

jwt.plugin.ts
import {Server} from '@hapi/hapi'; import {jwtHelper} from '@helpers'; const JWTPlugin = { name: 'JwtPlugin', version: '1.0.0', async register(server: Server){ const usersRedisBucket = server.cache({ cache: 'retrobie_cache', expiresIn: jwtHelper.getExpiryDuration('refresh_token'), segment: 'users', }); const refreshTokenRedisBucket = server.cache({ cache: 'retrobie_cache', expiresIn: jwtHelper.getExpiryDuration('refresh_token'), segment: 'refresh_tokens', }); //.. server.method('getRefreshTokensForUser', userId => usersRedisBucket.get(userId) ); server.method('dropRefreshTokenFromBucket', async ({jti}) => { await refreshTokenRedisBucket.drop(jti); }, ); } }

Then, a wrapper to make the method friendlier to use:

jwt.helper.ts
import {Server} from '@hapi/hapi'; export default class { //... async revokeTokensForUser(req: Request) { await authService.revokeRefreshTokensForUser(req.server, req.payload as { userId: number }) return { update: "ok" } } }

Finally, to revoke all of a user's refresh tokens:

auth.service.ts
class AuthService { // ... async revokeRefreshTokensForUser(server: Server, {userId}: {userId: number}){ const refreshTokenState = await jwtHelper.getRefreshTokenIdsForUser(server, userId) for (const token of refreshTokenState.tokens) { await jwtHelper.dropRefreshTokenFromBucket(server, token); } } }

This long-ass read is now done! Go out into the world and create more secure, efficient systems. Remember, with great power comes great responsibility.

Considerations to Make

Like everything, this approach has its own set of complications. In particular:

  • The Redis server is now a single point of failure. If it goes down, so does the rest of the API. It's the best I could do for now. I'll look for ways to make it more fault-tolerant, but don't be too hopeful (I'm too lazy).

  • One refresh token can "father" any number of access tokens. Keeping track of all these will probably prove burdensome at some point. It would likely be a good idea to limit this number early on.

Conclusion

This article explored the fundamentals of JWT and provided a few ways to overcome the issues one encounters when attempting to implement jwt authentication.

Find the working code on Github.

PS: Things might change and break over time. Ping me or file a new issue and I'll take a look.

Opinions, Praise, Criticisms, Feedback.

I hope you enjoy this read as much as I enjoyed writing it.

It's been in the works for quite a few months now (mostly due to my procrastination), and I'm happy to finally show it to the world. Voice your opinions, praise, criticisms and feedback straight to my inbox. All is appreciated. Alternatively, join the conversation on this Reddit thread!

Have a great day/night! 😄

Updates

  • 6/05/22 - 10:11 - Added localStorage vs cookies link.

Liked this article? Consider leaving a donation. 😇

Loved this article? Consider leaving a donation to help keep this ambitious fish swimming.

Buy Me a CoffeeDonate with Paypal

btc: bc1qedugpzcgutcmm7qefkhc25eh5dwrwsz7dyleg3

Copyright © 2022 Bradley K.