Nest.js Tutorial

Implementing refresh tokens using JWT

Marcin Wanago
JavaScriptNestJSTypeScript

In the third part of this series, we’ve implemented authentication with JWT, Passport, cookies, and bcrypt. It leaves quite a bit of room for improvement. In this article, we look into refresh tokens.

You can find all of the code from this series in this repository

Why do we need refresh tokens?

So far, we’ve implemented JWT access tokens. They have a specific expiration time that should be short. If someone steals it from our user, the token is usable just until it expires.

After the user logs in successfully, we send back the access token. Let’s say that it has an expiry of 15 minutes. During this period, it can be used by the user to authenticate while making various requests to our API.

After the expiry time passes, the user needs to log in by again providing the username and password. This does not create the best user experience, unfortunately. On the other hand, increasing the expiry time of our access token might make our API less secure.

The solution to the above issue might be refresh tokens. The basic idea is that on a successful log-in, we create two separate JWT tokens. One is an access token that is valid for 15 minutes. The other one is a refresh token that has an expiry of a week, for example.

How refresh tokens work

The user saves both of the tokens in cookies but uses just the access token to authenticate while making requests. It works for 15 minutes without issues. Once the API states that the access token expires, the user needs to perform a refresh.

The crucial thing about storing tokens in cookies is that they should use the httpOnly flag. For more information, check out Cookies: explaining document.cookie and the Set-Cookie header

To refresh the token, the user needs to call a separate endpoint, called  /refresh. This time, the refresh token is taken from the cookies and sent to the API. If it is valid and not expired, the user receives the new access token. Thanks to that, there is no need to provide the username and password again.

Addressing some of the potential issues

Unfortunately, we need to consider the situation in which the refresh token is stolen. It is quite a sensitive piece of data, almost as much as the password.

We need to deal with the above issue in some way. The most straightforward way of doing so is changing the JWT secret once we know about the data leak. Doing that would render all of our refresh tokens invalid, and therefore, unusable.

We might not want to log out every user from our application, though. Assuming we know the affected user, we would like to make just one refresh token invalid. JWT is in its core stateless, though.

One of the solutions that we might stumble upon while browsing the web is a blacklist. Every time someone uses a refresh token, we check if it is in the blacklist first. Unfortunately, this does not seem like a solution that would have good enough performance. Checking the blacklist upon every token refresh and keeping it up-to-date might be a demanding task.

An alternative is saving the current refresh token in the database upon logging in. When someone performs a refresh, we check if the token kept in the database matches the provided one. If it is not the case, we reject the request. Thanks to doing the above, we can easily make the token of a particular person invalid by removing it from the database.

Logging out

So far, when the user logged out, we’ve just removed the JWT token from cookies. While this might be a viable solution for tokens with a short expiry time, it creates some issues with refresh tokens. Even though we removed the refresh token from the browser, it is still valid for a long time.

We can address the above issue by removing the refresh token from the database once the user logs out. If someone tries to use the refresh token before it expires, it is not possible anymore.

Preventing logging in on multiple devices

Let’s assume that we provide services that require a monthly payment. Allowing many people to use the same account at the same time might have a negative impact on our business.

Saving the refresh token upon logging in can help us deal with the above issue too. If someone uses the same user credentials successfully, it overwrites the refresh token stored in the database. Thanks to doing that, the previous person is not able to use the old refresh token anymore.

A potential database leak

We’ve mentioned that the refresh token is sensitive data. If it leaks out, the attacker can easily impersonate our user.

We have a similar case with the passwords. This is why we keep hashes of the passwords instead of just plain text. We can improve our refresh token solution similarly.

If we hash our refresh tokens before saving them in the database, we prevent the attacker from using them even if our database is leaked.

Implementation in NestJS

The first thing to do is to add new environment variables. We want the secret used for generating refresh token to be different.

1ConfigModule.forRoot({
2  validationSchema: Joi.object({
3    JWT_ACCESS_TOKEN_SECRET: Joi.string().required(),
4    JWT_ACCESS_TOKEN_EXPIRATION_TIME: Joi.string().required(),
5    JWT_REFRESH_TOKEN_SECRET: Joi.string().required(),
6    JWT_REFRESH_TOKEN_EXPIRATION_TIME: Joi.string().required(),
7    // ...
8  })
9}),

Now, let’s add the column in our User entity so that we can save the refresh tokens in the database.

1import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
2import { Exclude } from 'class-transformer';
3 
4@Entity()
5class User {
6  @PrimaryGeneratedColumn()
7  public id: number;
8 
9  @Column({ unique: true })
10  public email: string;
11  
12  @Column({
13    nullable: true
14  })
15  @Exclude()
16  public currentHashedRefreshToken?: string;
17  
18  // ...
19}
20 
21export default User;

We also need to create a function for creating a method for creating a cookie with the refresh token.

1import { Injectable } from '@nestjs/common';
2import { UsersService } from '../users/users.service';
3import { JwtService } from '@nestjs/jwt';
4import { ConfigService } from '@nestjs/config';
5import TokenPayload from './tokenPayload.interface';
6 
7@Injectable()
8export class AuthenticationService {
9  constructor(
10    private readonly usersService: UsersService,
11    private readonly jwtService: JwtService,
12    private readonly configService: ConfigService
13  ) {}
14  
15  public getCookieWithJwtAccessToken(userId: number) {
16    const payload: TokenPayload = { userId };
17    const token = this.jwtService.sign(payload, {
18      secret: this.configService.get('JWT_ACCESS_TOKEN_SECRET'),
19      expiresIn: `${this.configService.get('JWT_ACCESS_TOKEN_EXPIRATION_TIME')}s`
20    });
21    return `Authentication=${token}; HttpOnly; Path=/; Max-Age=${this.configService.get('JWT_ACCESS_TOKEN_EXPIRATION_TIME')}`;
22  }
23 
24  public getCookieWithJwtRefreshToken(userId: number) {
25    const payload: TokenPayload = { userId };
26    const token = this.jwtService.sign(payload, {
27      secret: this.configService.get('JWT_REFRESH_TOKEN_SECRET'),
28      expiresIn: `${this.configService.get('JWT_REFRESH_TOKEN_EXPIRATION_TIME')}s`
29    });
30    const cookie = `Refresh=${token}; HttpOnly; Path=/; Max-Age=${this.configService.get('JWT_REFRESH_TOKEN_EXPIRATION_TIME')}`;
31    return {
32      cookie,
33      token
34    }
35  }
36  
37  // ...
38}
The possibility to provide the secret while calling the  jwtService.sign method has been added in the  7.1.0  version of  @nestjs/jwt

An improvement to the above would be to fiddle with the  Path parameter of the refresh token cookie so that the browser does not send it with every request.

We also need to create a method for saving the hash of the current refresh token.

1import { Injectable } from '@nestjs/common';
2import { InjectRepository } from '@nestjs/typeorm';
3import { Repository } from 'typeorm';
4import User from './user.entity';
5import * as bcrypt from 'bcrypt';
6 
7@Injectable()
8export class UsersService {
9  constructor(
10    @InjectRepository(User)
11    private usersRepository: Repository<User>,
12  ) {}
13 
14  async setCurrentRefreshToken(refreshToken: string, userId: number) {
15    const currentHashedRefreshToken = await bcrypt.hash(refreshToken, 10);
16    await this.usersRepository.update(userId, {
17      currentHashedRefreshToken
18    });
19  }
20 
21  // ...
22}

Let’s make sure that we send both cookies when logging in.

1import {
2  Req,
3  Controller,
4  HttpCode,
5  Post,
6  UseGuards,
7  ClassSerializerInterceptor, UseInterceptors,
8} from '@nestjs/common';
9import { AuthenticationService } from './authentication.service';
10import RequestWithUser from './requestWithUser.interface';
11import { LocalAuthenticationGuard } from './localAuthentication.guard';
12 
13@Controller('authentication')
14@UseInterceptors(ClassSerializerInterceptor)
15export class AuthenticationController {
16  constructor(
17    private readonly authenticationService: AuthenticationService
18  ) {}
19 
20  @HttpCode(200)
21  @UseGuards(LocalAuthenticationGuard)
22  @Post('log-in')
23  async logIn(@Req() request: RequestWithUser) {
24    const {user} = request;
25    const accessTokenCookie = this.authenticationService.getCookieWithJwtAccessToken(user.id);
26    const refreshTokenCookie = this.authenticationService.getCookieWithJwtRefreshToken(user.id);
27 
28    await this.usersService.setCurrentRefreshToken(refreshToken, user.id);
29 
30    request.res.setHeader('Set-Cookie', [accessTokenCookie, refreshTokenCookie]);
31    return user;
32  }
33 
34  // ...
35}

Creating an endpoint that uses the refresh token

Now we can start handling the incoming refresh token. For starters, let’s deal with checking if the token from cookies matches the one in the database. To do that, we need to create a new method in the  UsersService.

1import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
2import { InjectRepository } from '@nestjs/typeorm';
3import { Repository } from 'typeorm';
4import User from './user.entity';
5import { FilesService } from '../files/files.service';
6import * as bcrypt from 'bcrypt';
7 
8@Injectable()
9export class UsersService {
10  constructor(
11    @InjectRepository(User)
12    private usersRepository: Repository<User>,
13    private readonly filesService: FilesService
14  ) {}
15 
16  async getById(id: number) {
17    const user = await this.usersRepository.findOne({ id });
18    if (user) {
19      return user;
20    }
21    throw new HttpException('User with this id does not exist', HttpStatus.NOT_FOUND);
22  }
23 
24  async getUserIfRefreshTokenMatches(refreshToken: string, userId: number) {
25    const user = await this.getById(userId);
26 
27    const isRefreshTokenMatching = await bcrypt.compare(
28      refreshToken,
29      user.currentHashedRefreshToken
30    );
31 
32    if (isRefreshTokenMatching) {
33      return user;
34    }
35  }
36  
37  // ...
38}

Now, we need to create a new strategy for Passport. Please note that we use the  passReqToCallback parameter so that we can access the cookies in our  validate method.

1import { ExtractJwt, Strategy } from 'passport-jwt';
2import { PassportStrategy } from '@nestjs/passport';
3import { Injectable } from '@nestjs/common';
4import { ConfigService } from '@nestjs/config';
5import { Request } from 'express';
6import { UsersService } from '../users/users.service';
7import TokenPayload from './tokenPayload.interface';
8 
9@Injectable()
10export class JwtRefreshTokenStrategy extends PassportStrategy(
11  Strategy,
12  'jwt-refresh-token'
13) {
14  constructor(
15    private readonly configService: ConfigService,
16    private readonly userService: UsersService,
17  ) {
18    super({
19      jwtFromRequest: ExtractJwt.fromExtractors([(request: Request) => {
20        return request?.cookies?.Refresh;
21      }]),
22      secretOrKey: configService.get('JWT_REFRESH_TOKEN_SECRET'),
23      passReqToCallback: true,
24    });
25  }
26 
27  async validate(request: Request, payload: TokenPayload) {
28    const refreshToken = request.cookies?.Refresh;
29    return this.userService.getUserIfRefreshTokenMatches(refreshToken, payload.userId);
30  }
31}

To use the above strategy, we also need to create a new guard.

1import { Injectable } from '@nestjs/common';
2import { AuthGuard } from '@nestjs/passport';
3 
4@Injectable()
5export default class JwtRefreshGuard extends AuthGuard('jwt-refresh-token') {}

Now, the last thing to do is to create the  /refresh endpoint.

1import {
2  Req,
3  Controller,
4  UseGuards,
5  Get, ClassSerializerInterceptor, UseInterceptors,
6} from '@nestjs/common';
7import { AuthenticationService } from './authentication.service';
8import RequestWithUser from './requestWithUser.interface';
9import JwtRefreshGuard from './jwt-refresh.guard';
10 
11@Controller('authentication')
12@UseInterceptors(ClassSerializerInterceptor)
13export class AuthenticationController {
14  constructor(
15    private readonly authenticationService: AuthenticationService,
16  ) {}
17 
18  @UseGuards(JwtRefreshGuard)
19  @Get('refresh')
20  refresh(@Req() request: RequestWithUser) {
21    const accessTokenCookie = this.authenticationService.getCookieWithJwtAccessToken(request.user.id);
22 
23    request.res.setHeader('Set-Cookie', accessTokenCookie);
24    return request.user;
25  }
26  
27  // ...
28}

Improving the log-out flow

The last thing is to modify the log-out flow. First, let’s create a method that generates cookies to clear both the access token and the refresh token.

1import { Injectable } from '@nestjs/common';
2 
3@Injectable()
4export class AuthenticationService {
5  public getCookiesForLogOut() {
6    return [
7      'Authentication=; HttpOnly; Path=/; Max-Age=0',
8      'Refresh=; HttpOnly; Path=/; Max-Age=0'
9    ];
10  }
11  
12  // ...
13}

Now, we need to create a piece of code that removes the refresh token from the database.

1import { Injectable } from '@nestjs/common';
2import { InjectRepository } from '@nestjs/typeorm';
3import { Repository } from 'typeorm';
4import User from './user.entity';
5 
6@Injectable()
7export class UsersService {
8  constructor(
9    @InjectRepository(User)
10    private usersRepository: Repository<User>,
11  ) {}
12 
13  async removeRefreshToken(userId: number) {
14    return this.usersRepository.update(userId, {
15      currentHashedRefreshToken: null
16    });
17  }
18  
19  // ...
20}

Let’s add all of the above to our  /log-out endpoint.

1import {
2  Req,
3  Controller,
4  HttpCode,
5  Post,
6  UseGuards,
7  ClassSerializerInterceptor, UseInterceptors,
8} from '@nestjs/common';
9import { AuthenticationService } from './authentication.service';
10import RequestWithUser from './requestWithUser.interface';
11import JwtAuthenticationGuard from './jwt-authentication.guard';
12import { UsersService } from '../users/users.service';
13 
14@Controller('authentication')
15@UseInterceptors(ClassSerializerInterceptor)
16export class AuthenticationController {
17  constructor(
18    private readonly authenticationService: AuthenticationService,
19    private readonly usersService: UsersService
20  ) {}
21  
22  @UseGuards(JwtAuthenticationGuard)
23  @Post('log-out')
24  @HttpCode(200)
25  async logOut(@Req() request: RequestWithUser) {
26    await this.usersService.removeRefreshToken(request.user.id);
27    request.res.setHeader('Set-Cookie', this.authenticationService.getCookiesForLogOut());
28  }
29  
30  // ...
31}

Summary

By doing all of the above, we now have a fully functional refresh token flow. We also addressed a few issues that you might face when implementing authentication, such as potential database leaks and unwanted logging in on multiple devices. There is still a place for further improvements, such as making fewer queries to the database when authenticating with the access token.

What are your thoughts on the solutions implemented in this article?