Revoking JWT with Redis in NestJS

Introduction

In the previous article, I provided instructions on using NestJS with JWT, and you may also realize that if you use JWT, once a token is issued, it cannot be revoked. This means that if you have a token that hasn't expired yet, you can continue to use the service. For small systems that do not prioritize security, this might not be a major issue and can be simply resolved by deleting the token from the frontend when the user logs out.

However, if you need to build a system with extremely high security, where the token must be invalidated upon logout so that no one can use it to access the service, this article will guide you through how to achieve that.

To do this, we will use Redis (which I have already guided you on in this article) to store tokens that have not expired but are requested to be deleted. The storage duration for these tokens will be exactly the time remaining until they expire. Thus, after applying Redis, the operation of tokens will be as follows:

  • If a token is used normally until it expires, it can no longer be used.
  • If a token has not expired but the user logs out and requests that this token can no longer be used, we will store this token in Redis with a time-to-live equal to the remaining time until that token expires. When there is a request to continue using this token, we will check if it exists in Redis; if it does, we will not allow its use.

Here, we use Redis to achieve Instant Revocation, with extremely low latency because Redis reads/writes on RAM.

Prerequisites

You should review my previous article to grasp the basic information about JWT and have successfully started Valkey (Redis) before proceeding, as I will continue developing based on that project.


Detail

First, please update the jwt.guard.ts file

import {
ExecutionContext,
Inject,
Injectable,
UnauthorizedException,
} from '@nestjs/common'
import {AuthGuard} from '@nestjs/passport'
import {Redis} from 'ioredis'

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(@Inject('VALKEY_CLIENT') private readonly valkey: Redis) {
super()
}

async canActivate(context: ExecutionContext): Promise<boolean> {
const isValid = await super.canActivate(context)
if (!isValid) return false

const request = context.switchToHttp().getRequest()
const token = request.headers.authorization?.split(' ')[1]
const isBlacklisted = await this.valkey.get(token)
if (isBlacklisted) throw new UnauthorizedException()
return true
}
}

This code snippet acts as a "gatekeeper." After technically verifying the token's validity, it proceeds to look up in Redis whether this token is on the "blacklist" (logged out). If found in Redis, it will immediately deny access.


Next, update the auth.service.ts file

import {Inject, Injectable, UnauthorizedException} from '@nestjs/common'
import {JwtService} from '@nestjs/jwt'
import Redis from 'ioredis'
import {LoginDto} from 'src/dto/auth.dto'
import {EnvironmentService} from './environment.service'

const USERS = [
{id: 1, username: 'admin', password: 'password-admin', fullName: 'Admin'},
{id: 2, username: 'user1', password: 'password-user1', fullName: 'User 1'},
]

@Injectable()
export class AuthService {
constructor(
private readonly jwtService: JwtService,
private readonly envService: EnvironmentService,
@Inject('VALKEY_CLIENT') private readonly valkey: Redis
) {}

async login(info: LoginDto) {
const user = USERS.find(
u => u.username === info.username && u.password === info.password
)
if (!user) throw new UnauthorizedException()
return this.getTokens(user.username)
}

async logout(token: string) {
const payload = this.jwtService.decode(token) as any
const currentTime = Math.floor(Date.now() / 1000)
const timeLeft = payload.exp - currentTime

if (timeLeft > 0) {
await this.valkey.set(token, 'true', 'EX', timeLeft)
}
}

async refreshTokens(username: string) {
const user = USERS.find(u => u.username === username)
if (!user) throw new UnauthorizedException()
return this.getTokens(user.username)
}

getProfile(username: string) {
const user = USERS.find(u => u.username === username)
return user ? {...user, password: undefined} : null
}

async getTokens(username: string) {
const payload = {username}
const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(payload, {
secret: this.envService.jwtSecret,
expiresIn: this.envService.jwtExpires,
}),
this.jwtService.signAsync(payload, {
secret: this.envService.jwtRefreshSecret,
expiresIn: this.envService.jwtExpiresRefresh,
}),
])

return {
accessToken,
refreshToken,
expiresIn: Date.now() + this.envService.jwtExpires * 1000,
}
}
}

This service handles the core business logic for logout. When a user calls the /logout api, the system calculates the remaining time of the token (before it naturally expires) and puts that token into Redis to invalidate it for that remaining duration.

Automatic cleanup: Thanks to Redis's EX (Expire) mechanism, expired tokens will automatically disappear from memory, preventing Redis from bloating over time.


Update the auth.controller.ts file to add the /logout api

import {
Body,
Controller,
Get,
Post,
Req,
UnauthorizedException,
UseGuards,
} from '@nestjs/common'
import {LoginDto} from 'src/dto/auth.dto'
import {JwtRefreshAuthGuard} from 'src/guard/jwt-refresh.guard'
import {JwtAuthGuard} from 'src/guard/jwt.guard'
import {AuthService} from 'src/service/auth.service'

@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}

@Post('login')
async login(@Body() body: LoginDto) {
return this.authService.login(body)
}

@Post('logout')
@UseGuards(JwtAuthGuard)
async logout(@Req() req) {
const token = req?.headers?.authorization?.split(' ')?.[1]
return this.authService.logout(token)
}

@Post('refresh')
@UseGuards(JwtRefreshAuthGuard)
async refresh(@Req() req) {
return this.authService.refreshTokens(req?.user?.username)
}

@Get('profile')
@UseGuards(JwtAuthGuard)
async getProfile(@Req() req) {
const profile = this.authService.getProfile(req?.user?.username)
if (!profile) throw new UnauthorizedException()
return profile
}
}


Please check again with Postman, after logging out, you will no longer be able to reuse that access token.



Happy coding!

See more articles here.

Comments

Popular posts from this blog

All Practice Series

Deploying a NodeJS Server on Google Kubernetes Engine

Kubernetes Deployment for Zero Downtime

Setting up Kubernetes Dashboard with Kind

Using Kafka with Docker and NodeJS

Monitoring with cAdvisor, Prometheus and Grafana on Docker

Practicing with Google Cloud Platform - Google Kubernetes Engine to deploy nginx

Kubernetes Practice Series

NodeJS Practice Series

Sitemap