DEV Community

Cover image for How to Implement Refresh Tokens with Token Rotation in NestJS
zenstok
zenstok

Posted on

How to Implement Refresh Tokens with Token Rotation in NestJS

In this episode, we will learn how to implement refresh tokens using local storage as a strategy for storing both access and refresh tokens. If you want to jump directly to the GitHub repo, you can access it here.

Prerequisites

Before diving into this guide, it's important to have some experience with NestJS and the implementation of Passport strategies in NestJS. If you're not familiar with these topics, please visit the following articles from the NestJS documentation:

Why Do We Use Refresh Tokens?

Even though we use HTTPS to encrypt network traffic, there are additional steps we can take to prevent malicious users from stealing access tokens through methods like social engineering or library hacks. Refresh tokens allow a user to stay logged in for a long time without needing to log in again, provided they are active users. We can log them out after a set period, such as six months, if they have been inactive.

Why use refresh tokens instead of a single access token with a long or no expiration date? Because we want to counter token theft with short-lived access tokens, so attackers are likely to obtain expired tokens. Additionally, refresh tokens can provide a way to revoke user access without resetting the JWT signing key and logging out all users.

Implementation Overview

When we log in for the first time, we receive a token pair that includes an access token and a refresh token. As the access token approaches expiration, we can obtain a new token pair by requesting a new set of tokens from our authentication server. The server will provide the new token pair only if the refresh token is provided.

Why do you think I mentioned receiving a new token pair consisting of both access token and refresh token? If I have the refresh token with a long expiration date, wouldn't it have been sufficient to just get a new access token? Well, storing the refresh token with long expiration date invalidates the logic that hackers obtain short-lived tokens that are likely to be expired. Furthermore, this approach eliminates the possibility of implementing the 'permanently logged in' users feature, even if they are active. So, what we do is when we request a new token pair, we immediately invalidate the previous refresh token through a mechanism called refresh token rotation.

Refresh Token Rotation

Refresh token rotation operates by generating a blacklist which will "force invalidate" previously used refresh tokens. When a new token pair is requested, we utilize a refresh token and then include this used refresh token in our blacklist. This means that if a hacker gains control of a refresh token, it will already be invalid if the user has refreshed their token pair.

But what if the hacker gets a fresh, valid refresh token? You've got two options: if you spot the hack right away, though that's unlikely, you can quickly get a new token pair to make the hacker's refresh token worthless. If the hacker uses your refresh token and it's marked invalid, there's not much you can do. They might have access to your app indefinitely until you change the JWT signing key. To stop this, we can store the refresh token in an HTTP-only cookie and guard against CSRF attacks. That way, even if your app has XSS vulnerabilities, the hacker can't read the refresh token. If you're interested in an article about storing refresh tokens in HTTP-only cookies, leave a comment, and I'll get right on it.

Getting Started

For this article, we'll focus on the core logic and keep the app simple.

Clone the project from GitHub and start the Docker containers:

yarn dc up
Enter fullscreen mode Exit fullscreen mode

Pre-fill your database with two users:

yarn dc-db-init
Enter fullscreen mode Exit fullscreen mode

Access Swagger at localhost:3000/docs to log in. For the admin user, use:
Email: admin@admin.com
Password: 1234

Our app allows refreshing the token pair by calling the /refresh-tokens endpoint. When called with a refresh token as bearer auth, it invalidates the previous token. Try calling the endpoint twice with the same token to see the 401 Unauthorized error on the second call.

The core logic is in the authentication module. We have three guards:

  • Local Auth Guard: For initial authentication with email and password.
  • JWT Auth Guard: Protects all app routes globally, defined as an APP_GUARD in app.module.ts, uses access token for validation.
  • JWT Refresh Auth Guard: Guards the /refresh-tokens endpoint, uses refresh token for validation.

The critical aspect here is the interaction between access tokens and refresh tokens, so I'll skip discussing the local auth guard. For the JWT auth guard, we utilize the JWT strategy from the 'passport-jwt' package.

In the following section, we define how to extract the JWT from the request and the JWT signature key, which we set in the environment. In the validate method, we receive the payload of the JWT, which we use to retrieve the user ID and verify if the user exists in the database before granting access if true.

export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(
    private userService: UserService,
    configService: ConfigService<EnvironmentVariables>,
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: configService.get('jwtSecret'),
    });
  }

  async validate(payload: any): Promise<User | null> {
    const authUser = await this.userService.findOne(payload.sub);
    if (!authUser) {
      throw new UnauthorizedException();
    }
    return authUser;
  }
}
Enter fullscreen mode Exit fullscreen mode

Similarly, for the JWT refresh auth guard, we employ the same JWT strategy from the 'passport-jwt' package. The distinction here from the JWT strategy file is that we utilize a different secret key for JWT token generation, and we return both the user attributes and the refresh token expiration date. This expiration date becomes necessary later in the process.

@Injectable()
export class JwtRefreshStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
  constructor(
    private userService: UserService,
    configService: ConfigService<EnvironmentVariables>,
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: configService.get('jwtRefreshSecret'),
    });
  }

  async validate(payload: any) {
    const authUser = await this.userService.findOne(payload.sub);
    if (!authUser) {
      throw new UnauthorizedException();
    }
    return {
      attributes: authUser,
      refreshTokenExpiresAt: new Date(payload.exp * 1000),
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

In the authentication.controller's login method, we observe that we call the login method, which, in turn, invokes the generateTokenPair from our AuthRefreshTokenService. It's important to note that we also implement a throttle mechanism to limit the number of requests on the login route, thereby preventing brute force attacks, with a maximum of 2 requests per second and a maximum of 5 login attempts per 60 seconds.

@Throttle({ short: { limit: 2, ttl: 1000 }, long: { limit: 5, ttl: 60000 } })
@ApiBody({ type: UserLoginDto })
@Public()
@UseGuards(LocalAuthGuard)
@Post('login')
login(@Request() req: any) {
  return this.authenticationService.login(req.user);
}
Enter fullscreen mode Exit fullscreen mode

From within authentication service:

login(user: User) {
  return this.authRefreshTokenService.generateTokenPair(user);
}
Enter fullscreen mode Exit fullscreen mode

The auth.refresh.token.service.ts looks like this:

export class AuthRefreshTokenService {
  constructor(
    private jwtService: JwtService,
    private configService: ConfigService<EnvironmentVariables>,
    @InjectRepository(AuthRefreshToken)
    private authRefreshTokenRepository: Repository<AuthRefreshToken>,
  ) {}

  async generateRefreshToken(authUserId: number, currentRefreshToken?: string, currentRefreshTokenExpiresAt?: Date) {
    const newRefreshToken = this.jwtService.sign(
      { sub: authUserId },
      { secret: this.configService.get('jwtRefreshSecret'), expiresIn: '30d' },
    );

    if (currentRefreshToken && currentRefreshTokenExpiresAt) {
      if (await this.isRefreshTokenBlackListed(currentRefreshToken, authUserId)) {
        throw new UnauthorizedException('Invalid refresh token.');
      }

      await this.authRefreshTokenRepository.insert({
        refreshToken: currentRefreshToken,
        expiresAt: currentRefreshTokenExpiresAt,
        userId: authUserId,
      });
    }

    return newRefreshToken;
  }

  private isRefreshTokenBlackListed(refreshToken: string, userId: number) {
    return this.authRefreshTokenRepository.existsBy({ refreshToken, userId });
  }

  async generateTokenPair(user: User, currentRefreshToken?: string, currentRefreshTokenExpiresAt?: Date) {
    const payload = { email: user.email, sub: user.id };
    return {
      access_token: this.jwtService.sign(payload),
      refresh_token: await this.generateRefreshToken(user.id, currentRefreshToken, currentRefreshTokenExpiresAt),
    };
  }

  @Cron(CronExpression.EVERY_DAY_AT_6AM)
  async clearExpiredRefreshTokens() {
    await this.authRefreshTokenRepository.delete({ expiresAt: LessThanOrEqual(new Date()) });
  }
}
Enter fullscreen mode Exit fullscreen mode

Looking at generateRefreshToken method, we generate a new refresh token with a 30-days expiration. If we don't receive the optional currentRefreshToken and currentRefreshTokenExpiresAt parameters, we simply return the newly created refresh token, as expected after a successful login.

Examining the refreshTokens method below in the authentication controller, we notice the implementation of a throttle mechanism: a maximum of 1 request per second or 2 requests per 60 seconds. We invoke generateTokenPair with user attributes, the used refresh token, and its expiration date:

@Throttle({
  short: { limit: 1, ttl: 1000 },
  long: { limit: 2, ttl: 60000 },
})
@ApiBearerAuth()
@Public()
@UseGuards(JwtRefreshAuthGuard)
@Post('refresh-tokens')
refreshTokens(@Request() req: ExpressRequest) {
  if (!req.user) {
    throw new InternalServerErrorException();
  }
  return this.authRefreshTokenService.generateTokenPair(
    (req.user as any).attributes,
    req.headers.authorization?.split(' ')[1],
    (req.user as any).refreshTokenExpiresAt,
  );
}
Enter fullscreen mode Exit fullscreen mode

The generateTokenPair method of AuthRefreshTokenService, when invoked with currentRefreshToken and currentRefreshTokenExpiresAt, checks if the current token is blacklisted and throws an error if it's reused. For the first-time usage, it inserts this token into our auth refresh tokens database table, effectively acting as our blacklist.

In the final method of our service, we have a cron job responsible for deleting all refresh tokens whose expiration date has passed, as we no longer need to retain them in the database.

This is part 1 of a 3-episode series. In the next episode, I will show you how to manage access and refresh tokens easily in a React app. In episode 3, we'll delve deeper into storing the refresh token in an HTTP-only cookie instead of local storage. This approach prevents attackers from reading the refresh token, even if your app is vulnerable to XSS attacks.

If you'd like me to cover more interesting topics about the node.js ecosystem, feel free to leave your suggestions in the comments section. Don't forget to subscribe to my newsletter on rabbitbyte.club for updates!

Top comments (3)

Collapse
 
zac_palmer_fe2402c5b0d69d profile image
Zac Palmer

Will you make the post on http only refresh tokens. Also if you could make a post about making a https api would be great I have been trying to find resources on how to do so and it's hard to find.

Collapse
 
zenstok profile image
zenstok

Hi there! Of course, I was already planning on posting the article about storing the refresh token in an HTTP-only cookie as the third episode of this series. Now that it looks like it's a demanded topic, I will speed up the process. Thanks for your comment!

Collapse
 
zenstok profile image
zenstok

Regarding https api, what do you mean exactly? How to deploy a REST API which is available through https only?