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
Pre-fill your database with two users:
yarn dc-db-init
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
inapp.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;
}
}
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),
};
}
}
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);
}
From within authentication service:
login(user: User) {
return this.authRefreshTokenService.generateTokenPair(user);
}
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()) });
}
}
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,
);
}
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!
Post Creation:
Check out Part 2 of this series, where we integrate this backend with a React App.
Top comments (6)
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.
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!
Regarding https api, what do you mean exactly? How to deploy a REST API which is available through https only?
Great article! Could you possibly include a section on replacing localStorage with HTTP secure cookies for storing JWTs? This would enhance security by reducing the risk of XSS attacks.
@zenstok
Yes! I'm working on the third part of this series which addresses exactly this.
Nice article! Can't wait for more parts of this episode. 🔥