NestJS course Β· Module 4: Authentication

Refresh Tokens - renewing legion passes

9 min read
In this lesson9

The access token from the previous lessons lives for 15 minutes. A legionary who gets thrown out of the system every quarter of an hour will soon start cursing Architect Vitruvius. Stretching the pass to a week, on the other hand, is an open invitation to thieves: a JWT stays valid until its expiry date, so a stolen token opens the fort for seven days and cannot easily be revoked. The way out is two tokens with different jobs.

What are Refresh Tokens?

Imagine the pass system in a legionary camp. A legionary receives two documents:

  • Access Token (a short sentry pass) - lets him into the camp and expires after about a quarter of an hour
  • Refresh Token (the centurion's seal) - lives for several days and lets him obtain a new pass without standing before the tribunal again

Why do we need two tokens?

  • Security - if someone intercepts the access token, they have access only for a short time
  • Convenience - the user does not have to log in every quarter of an hour
  • Control - we can invalidate the refresh token, blocking access renewal

The refresh token mechanism is the foundation of secure authentication systems in production applications.

Generating a token pair

We sign the two tokens with separate secrets, JWT_ACCESS_SECRET and JWT_REFRESH_SECRET, so neither can be forged or used in place of the other. This is the login:

1// auth/auth.service.ts
2import { Injectable, UnauthorizedException } from '@nestjs/common';
3import { JwtService } from '@nestjs/jwt';
4import { ConfigService } from '@nestjs/config';
5import { createHash } from 'node:crypto';
6
7@Injectable()
8export class AuthService {
9  constructor(
10    private jwtService: JwtService,
11    private configService: ConfigService,
12    private usersService: UsersService,
13  ) {}
14
15  async login(loginDto: LoginDto): Promise<TokenPair> {
16    const user = await this.usersService.validateCredentials(
17      loginDto.username,
18      loginDto.password,
19    );
20
21    if (!user) {
22      throw new UnauthorizedException('Invalid login credentials, legionary!');
23    }
24
25    // Generate a token pair
26    const tokens = await this.generateTokenPair(user);
27
28    // Store the SHA-256 digest of the refresh token, not the token itself
29    const hashedRefreshToken = this.hashToken(tokens.refreshToken);
30    await this.usersService.updateRefreshToken(user.id, hashedRefreshToken);
31
32    return tokens;
33  }

validateCredentials() checks the password with bcrypt.compare() from the hashing lesson. Only the digest of the refresh token lands in the database - we store the access token nowhere, because it expires within a quarter of an hour anyway. You will meet the UserDocument type in a moment, with the schema.

A separate method creates the token pair:

1  async generateTokenPair(user: UserDocument): Promise<TokenPair> {
2    const payload = {
3      sub: user.id,
4      username: user.username,
5      role: user.role,
6      cohort: user.cohortName,
7    };
8
9    const [accessToken, refreshToken] = await Promise.all([
10      this.jwtService.signAsync(payload, {
11        secret: this.configService.get('JWT_ACCESS_SECRET'),
12        expiresIn: '15m', // Short sentry pass - 15 minutes
13      }),
14      this.jwtService.signAsync(payload, {
15        secret: this.configService.get('JWT_REFRESH_SECRET'),
16        expiresIn: '7d', // Centurion's seal - longer lifetime
17      }),
18    ]);
19
20    return { accessToken, refreshToken };
21  }
22
23  // Refresh token digest: SHA-256, because bcrypt reads only 72 bytes
24  private hashToken(token: string): string {
25    return createHash('sha256').update(token).digest('hex');
26  }
27}

signAsync() takes a secret and an expiresIn for each token separately, and Promise.all() signs both in parallel. The payload is the same; only the secret and the lifetime differ. Notice hashToken(): it deliberately uses SHA-256, not bcrypt. bcrypt reads only 72 bytes, and two JWTs of the same legionary share an identical beginning (the header and the first payload fields), so bcrypt.compare(oldToken, hashOfNewOne) would return true. The token is long and random, so a fast digest is more than enough.

One thing outside this file: JwtStrategy must verify access tokens with the same secret you sign them with. If you introduce JWT_ACCESS_SECRET, change its secretOrKey as well.

The token renewal endpoint

The controller exposes three routes: login, refresh and logout:

1// auth/auth.controller.ts
2@Controller('auth')
3export class AuthController {
4  constructor(private authService: AuthService) {}
5
6  @Post('login')
7  async login(@Body() loginDto: LoginDto) {
8    return this.authService.login(loginDto);
9  }
10
11  @Post('refresh')
12  async refreshTokens(@Body('refreshToken') refreshToken: string) {
13    return this.authService.refreshTokens(refreshToken);
14  }
15
16  @Post('logout')
17  @UseGuards(JwtAuthGuard)
18  async logout(@Req() req) {
19    // Invalidate refresh token on logout
20    await this.authService.logout(req.user.userId);
21    return { message: 'Legionary logged out successfully' };
22  }
23}

/auth/refresh has no JwtAuthGuard, because we call it precisely when the access token has expired - the refresh token itself is the credential here. Logout is protected, and req.user.userId comes from JwtStrategy, whose validate() returns { userId, username, role }.

Renewal logic in the service

The refreshTokens() method starts by checking whether the seal is genuine and whose document it is:

1// auth/auth.service.ts (continued)
2async refreshTokens(refreshToken: string): Promise<TokenPair> {
3  // 1. Verify the refresh token
4  let payload;
5  try {
6    payload = await this.jwtService.verifyAsync(refreshToken, {
7      secret: this.configService.get('JWT_REFRESH_SECRET'),
8    });
9  } catch (error) {
10    throw new UnauthorizedException("Centurion's seal has expired - log in again!");
11  }
12
13  // 2. Find the user and check the stored refresh token
14  const user = await this.usersService.findById(payload.sub);
15
16  if (!user || !user.hashedRefreshToken) {
17    throw new UnauthorizedException('Access denied!');
18  }

verifyAsync() with the refresh secret checks the signature and the expiry date. A missing stored digest means the legionary logged out or his session was revoked.

Next we compare the token with the database and issue a new pair:

1  // 3. Compare the token's digest with the stored one
2  const isTokenValid = this.hashToken(refreshToken) === user.hashedRefreshToken;
3
4  if (!isTokenValid) {
5    // Potential token theft - invalidate all sessions
6    await this.usersService.updateRefreshToken(user.id, null);
7    throw new UnauthorizedException('Token invalidated - suspicious activity detected!');
8  }
9
10  // 4. Generate a new token pair (Token Rotation)
11  const newTokens = await this.generateTokenPair(user);
12
13  // 5. Save the new refresh token
14  const hashedNewRefreshToken = this.hashToken(newTokens.refreshToken);
15  await this.usersService.updateRefreshToken(user.id, hashedNewRefreshToken);
16
17  return newTokens;
18}
19
20async logout(userId: string): Promise<void> {
21  // Invalidate the refresh token
22  await this.usersService.updateRefreshToken(userId, null);
23}

If the digest does not match, someone brought a correctly signed but outdated token - a sign of theft, so we erase the stored digest and everyone has to log in again. logout() does the same at the user's request.

Token Rotation

A key security technique: on every refresh we generate a new refresh token and invalidate the old one. If someone steals a refresh token and tries to use it after the rotation, we will detect it:

1// Token theft protection scheme
2// 1. Legionary logs in -> receives AT1 + RT1
3// 2. AT1 expires -> sends RT1 -> receives AT2 + RT2 (RT1 invalidated)
4// 3. Thief tries to use RT1 -> DENIED (token already rotated)
5// 4. System detects use of old token -> invalidates ALL user sessions

The old RT1 still has a valid signature and expiry date. Only the comparison with the digest in the database rejects it - and that is why we keep the digest.

User schema with a refresh token

The digest goes into the hashedRefreshToken field of the Mongoose schema:

1// users/user.schema.ts (Mongoose)
2import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
3import { HydratedDocument } from 'mongoose';
4
5@Schema({ timestamps: true })
6export class User {
7  @Prop({ required: true, unique: true })
8  username: string;
9
10  @Prop({ required: true })
11  password: string;
12
13  @Prop()
14  email: string;
15
16  @Prop({ default: 'miles' })
17  role: string;
18
19  @Prop({ type: String, default: null })
20  hashedRefreshToken: string | null;
21
22  @Prop()
23  cohortName: string;
24}
25
26export type UserDocument = HydratedDocument<User>;
27export const UserSchema = SchemaFactory.createForClass(User);

The nullable option comes from TypeORM, and Mongoose simply ignores it. Here null is allowed by default, and with the string | null type you have to state type: String explicitly. HydratedDocument<User> is the pattern from the current NestJS documentation: the class describes the fields, and the document type adds id and the Mongoose methods.

Refresh Token Guard

Instead of verifying the token by hand, you can hand the job to Passport. A strategy named 'jwt-refresh' reads the token from a body field:

1// auth/guards/refresh-token.guard.ts
2import { Injectable } from '@nestjs/common';
3import { AuthGuard } from '@nestjs/passport';
4
5@Injectable()
6export class RefreshTokenGuard extends AuthGuard('jwt-refresh') {}
7
8// auth/strategies/refresh-token.strategy.ts
9import { Injectable } from '@nestjs/common';
10import { PassportStrategy } from '@nestjs/passport';
11import { ExtractJwt, Strategy } from 'passport-jwt';
12import { ConfigService } from '@nestjs/config';
13import { Request } from 'express';
14
15@Injectable()
16export class RefreshTokenStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
17  constructor(private configService: ConfigService) {
18    super({
19      jwtFromRequest: ExtractJwt.fromBodyField('refreshToken'),
20      secretOrKey: configService.getOrThrow('JWT_REFRESH_SECRET'),
21      passReqToCallback: true,
22    });
23  }
24
25  validate(req: Request, payload: any) {
26    const refreshToken = req.body.refreshToken;
27    return { ...payload, refreshToken };
28  }
29}

passReqToCallback: true passes the whole request to validate(), so the raw token ends up in req.user.refreshToken. The strategy only checks the signature and the date, which is why the comparison with the database digest stays in the service anyway. On the client, keep the refresh token in an httpOnly cookie that JavaScript cannot read - that is my recommendation.

Practical exercise

Implement a complete refresh token system for the legion, step by step:

1// Your code here
2// 1. Configure two JWT secrets (access and refresh) in ConfigService
3// 2. Implement the POST /auth/refresh endpoint with validation
4// 3. Add Token Rotation on each refresh
5// 4. Implement detection of reused old tokens
6// 5. Add cleanup of expired tokens (cron job)

For cleaning up old entries, @nestjs/schedule with its @Cron() decorator comes in handy.

Summary

You have been appointed guardian of the Empire's gates! Now you can:

  • Generate token pairs - access token (short) + refresh token (long)
  • Implement Token Rotation - a new refresh token on every refresh
  • Detect token theft - invalidating sessions when an old token is reused
  • Store refresh tokens safely in the database as a SHA-256 digest
  • Implement logout - invalidating the refresh token on logout
  • Create Passport Strategies for refresh tokens

In the next lesson we will take on OAuth and logging in with Google.

Remember: the sentry pass must be short and the centurion's seal single-use, because every rotation closes the road to whoever stole the previous one.

Code for this lesson: src/auth/refresh-tokens.ts
1// Refresh Tokens - Renewing the legion's passes
2import { Injectable, UnauthorizedException } from '@nestjs/common';
3import { JwtService } from '@nestjs/jwt';
4import { ConfigService } from '@nestjs/config';
5
6interface TokenPair {
7  accessToken: string;
8  refreshToken: string;
9}
10
11interface JwtPayload {
12  sub: string;
13  username: string;
14  role: string;
15}
16
17@Injectable()
18export class RefreshTokenService {
19  constructor(
20    private jwtService: JwtService,
21    private configService: ConfigService,
22  ) {}
23
24  // TODO: Implement token pair generation
25  // accessToken - short lifetime (15m)
26  // refreshToken - long lifetime (7d)
27  async generateTokenPair(payload: JwtPayload): Promise<TokenPair> {
28    // TODO: Generate accessToken
29    const accessToken = this.jwtService.sign(payload, {
30      secret: '', // TODO: fetch JWT_ACCESS_SECRET from configService
31      expiresIn: '', // TODO: set to '15m'
32    });
33
34    // TODO: Generate refreshToken with a different secret
35    const refreshToken = this.jwtService.sign(payload, {
36      secret: '', // TODO: fetch JWT_REFRESH_SECRET from configService
37      expiresIn: '', // TODO: set to '7d'
38    });
39
40    return { accessToken, refreshToken };
41  }
42
43  // TODO: Implement token refresh
44  async refreshTokens(oldRefreshToken: string): Promise<TokenPair> {
45    try {
46      // TODO: Verify old refreshToken
47      const payload = this.jwtService.verify(oldRefreshToken, {
48        secret: '', // TODO: same secret as during generation refresh
49      });
50
51      // TODO: Generate new token pair
52      const newPayload: JwtPayload = {
53        sub: payload.sub,
54        username: payload.username,
55        role: payload.role,
56      };
57
58      return this.generateTokenPair(newPayload);
59    } catch (error) {
60      throw new UnauthorizedException('Refresh token expired or is invalid!');
61    }
62  }
63}
64
65console.log('Access Token: short lifetime (15 minutes)');
66console.log('Refresh Token: long lifetime (7 days)');
67console.log('Token rotation increases security');
68

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. What is the typical difference in lifetime between an Access Token and a Refresh Token?

Hands-on tasks in the game

  • Vertical ordering

    Arrange the steps of JWT token refresh:

Useful articles