Securing user accounts is a critical aspect of any modern web application. Two-factor authentication (2FA) is an effective way to enhance security by requiring users to provide two forms of identification; typically a password and a second factor like a one-time code.
Time-Based One-Time Passwords (TOTP) are a widely used 2FA method. TOTP generates time-sensitive codes that users access through apps like Google Authenticator or Authy. These codes provide an additional layer of security, which makes it harder for attackers to gain unauthorized access.
This guide will walk you through implementing TOTP-based 2FA in a NestJS application. You'll learn how to generate TOTPs and QR codes for users to scan with their authenticator apps, verify the tokens, and integrate the process into your authentication flow.
How to Set Up TOTP 2FA in Your NestJS App
To follow along with this guide, you need to have the following:
- Node.js installed on your local machine
- Git CLI installed on your local machine
- Docker installed on your local machine
- Postman installed on your local machine
- An authenticator app such as Google Authenticator
- A code editor and a web browser
- Familiarity with the NestJS framework and TypeORM
Setting Up a NestJS Application
To keep this guide's focus on implementing TOTP 2FA, I have set up a starter template that you'll build on. To clone this template to your local machine, execute the command below:
git clone --single-branch -b starter-template https://github.com/kimanikevin254/nestjs-totp-2fa.git
Open the project in your code editor and explore the files. This project uses Docker to run a Postgres database as you can see in the docker-compose.yml
file in the project root folder. Let's look at the other key files in this project:
src/auth/auth.controller.ts
: Defines two routes that allow the user to sign up and sign in to the application.src/auth/auth.service.ts
: Defines a couple of auth-related methods for hashing the user's password before storing it in the database, generating an access token, signing up, and signing in.src/user/user.controller.ts
: Defines a protected route that allows authenticated users to access their profile information.src/user/entity.ts
: Defines the schema for the user model.src/user/service.ts
: Defines a couple of methods that create a user, retrieve a user by email and ID, and retrieve the user profile..env.example
: Defines the environment variables required for this project.
There is also a common
module that defines code used by the other modules such as loading environment variables, custom decorators, guards, and interfaces.
Now that you understand the project, you can go ahead to set it up. First, rename the .env.example
file to .env
. You can change some of these credentials or use them as they are.
Next, start the database by running the Docker containers using the command below:
docker compose up
Next, install all the dependencies using the command below:
npm i
You can now run the NestJS application using the command:
npm run start:dev
Once the application starts successfully, open your Postman application and make a POST request to "localhost:3000/api/auth/signup" with the following payload:
{
"name": "John Doe",
"email": "john@gmail.com",
"password": "JoHn12345!"
}
You should get a response with the access token and user ID:
Try to log in using the same credentials by sending a POST request to "localhost:3000/api/auth/signin":
Finally, send a GET request to "localhost:3000/api/user" with the access token you received in the previous step in the authorization header:
In the next section, you will implement TOTP 2FA. When users sign up, they will receive a QR code to scan with their authenticator app. For subsequent login attempts, they will need to provide the token displayed in the authenticator app.
Generating a Time-Based One-Time Password
You will use the OTPAuth library to generate TOTPs. To install this library, execute the following command in your terminal:
npm i otpauth
Next, you need to define a method to generate a time-based one-time password. But first, open the src/auth/auth.service.ts
file and add the following import statement:
import * as OTPAuth from "otpauth";
Next, add the following method to the AuthService
class:
private generateTOTP(email: string): OTPAuth.TOTP {
return new OTPAuth.TOTP({
issuer: 'NestJS App',
label: email,
algorithm: 'SHA256',
digits: 6,
period: 30,
secret: new OTPAuth.Secret(), // Generates a random cryptographically secure secret.
});
}
This method generates a Time-Based One-Time Password (TOTP) object. It takes the user's email as input and creates a new TOTP instance with the following settings:
- Issuer: Defines the provider that the account is associated with. In this case, you use the application name ('NestJS App').
- Label: Defines the identifier for the account. In this case, the label is the user's email.
- Algorithm: Define the algorithm used to generate the OTP.
- Digits: Defines the length of the OTP.
- Period: Defines how long an OTP is valid for.
- Secret: Defines a unique secret key user to generate the OTP.
Generating a QR Code
After generating a TOTP object, you will need a method that generates a QR code from the TOTP object and returns it to the user so that they can scan it using their authenticator app. You will use the node-qrcode library to generate QR codes. Install it using the command below:
npm i qrcode
Next, open the src/auth/auth.service.ts
file and add the following import statement:
import * as QRCode from "qrcode";
Next, add the following method to the AuthService
class:
private async generateQRCode(totp: OTPAuth.TOTP): Promise<string> {
const totpURI = OTPAuth.URI.stringify(totp)
return QRCode.toDataURL(totpURI);
}
This method generates a QR code for the provided TOTP object. It first converts the TOTP object into a URI (a string format) using OTPAuth.URI.stringify(totp)
. Then, it generates a QR code from that URI and returns it as a base64-encoded data URL, which can be used to display the QR code as an image.
Serializing the TOTP
You need to store the TOTP object in the database so that you can use it to validate the tokens provided by the user during the login process. But you cannot store the object as is. If you take a look at the User
entity in the src/user/user.entity.ts
file, you will notice that the totp
field accepts a string value. Therefore, you need a method that will convert the TOTP object into a string before storing it in the database. To achieve this, add the following method to the AuthService
class:
private serializeTOTP(totp: OTPAuth.TOTP) {
return JSON.stringify({
issuer: totp.issuer,
label: totp.label,
issuerInLabel: totp.issuerInLabel,
secret: totp.secret.hex,
algorithm: totp.algorithm,
digits: totp.digits,
period: totp.period,
});
}
This method converts the TOTP object into a JSON string. It extracts all the properties from the TOTP object and then formats them into a JSON structure. This allows the TOTP data to be saved or transferred in a standardized, easily readable format. You just can't use JSON.stringify(totp)
because the totp.secret
value is an instance of the Secret class. Using totp.secret.hex
for the secret
value extracts the secret in hex format which can easily be serialized.
Validating Tokens
When a user attempts to log in, you will need a method that can validate the token they provide for two-factor authentication. But first, remember that the totp object is saved as a string which means that you will need a method that can convert it back to an instance of the OTPAuth.TOTP
class. To achieve this, add the following method to the AuthService
class:
private deserializeTOTP(totpString: string): OTPAuth.TOTP {
const parsed = JSON.parse(totpString);
return new OTPAuth.TOTP({
issuer: parsed.issuer,
label: parsed.label,
issuerInLabel: parsed.issuerInLabel,
secret: OTPAuth.Secret.fromHex(parsed.secret),
algorithm: parsed.algorithm,
digits: parsed.digits,
period: parsed.period,
});
}
This method converts the TOTP string back into a TOTP
object. It first parses the string into an object and then uses the parsed data to create a new TOTP
instance, including the secret (converted back from hex) and other properties like issuer, label, algorithm, digits, and period. This allows the TOTP data to be restored to its original form for use in validation.
Next, add the following method that validates the token provided by the user:
private validateTOTP(token: string, totp: OTPAuth.TOTP) {
return totp.validate({ token, window: 1 });
}
This method checks if the provided token is valid by using the TOTP object. It calls the validate
method of the totp
instance, passing the token
and a window
value. The search window helps to account for clock drift between the client and the server. If the token is valid, it returns a number; otherwise, it returns null
.
Updating Existing Code to Accommodate 2FA
At this point, you have defined all the helper methods you need to implement 2FA. What remains is updating the already existing code to accommodate 2FA. To do this, open the src/auth/auth.service.ts
file and replace the signup
method with the following:
async signup(dto: SignUpDto) {
// Check if user exists
const user = await this.userService.findByEmail(dto.email);
if (user) {
throw new HttpException(
'Email address is already registered.',
HttpStatus.BAD_REQUEST,
);
}
// Hash password
const passwordHash = await this.hashPassword(dto.password);
const newUser = await this.userService.create({ ...dto, passwordHash });
// Generate TOTP object
const totp = this.generateTOTP(newUser.email);
// Serialize totp
const serializedTOTP = this.serializeTOTP(totp);
// Save user TOTP
await this.userService.updateUserTOTP(newUser.id, serializedTOTP);
// Generate QR code for the user to scan
const qrCode = await this.generateQRCode(totp);
return { image: qrCode };
}
You will define the
updateUserTOTP
method in theUserService
class later.
In addition to saving the user to the database, this method now generates a TOTP object, serializes it, updates the user information, and generates a QR code that the end user can scan.
Next, replace the login
method with the following:
async login(dto: LogInDto) {
const user = await this.userService.findByEmail(dto.email);
if (!user) {
throw new HttpException('Incorrect credentials', HttpStatus.UNAUTHORIZED);
}
// Check if password matches
const passwordMatches = await bcrypt.compare(
dto.password,
user.passwordHash,
);
if (!passwordMatches) {
throw new HttpException('Incorrect credentials', HttpStatus.UNAUTHORIZED);
}
return {
message: 'Provide the OTP from your Authenticator app',
userId: user.id,
};
}
After validating the user credentials, this method now returns a message that prompts the user to submit the token from their authenticator app for 2FA.
Next, add the following method that will validate the user's token and return an access token:
async twoFactorAuth(userId: string, token: string) {
// Retrieve user
const user = await this.userService.findById(userId);
// Deserialize saved totp
const deserializedTOTP = this.deserializeTOTP(user.totp);
// Validate token
if (this.validateTOTP(token, deserializedTOTP) === null) {
throw new HttpException('Invalid token', HttpStatus.UNAUTHORIZED);
}
const tokens = await this.generateTokens(user.id);
return {
tokens,
userId: user.id,
};
}
This method handles two-factor authentication for a user. It retrieves the user's data, recreates the TOTP object from the stored data, and validates the provided token. If the token is invalid, an exception is thrown. If valid, it generates new JWT tokens and returns them along with the user's ID.
Next, open the src/auth/auth.controller.ts
file and replace the signup
method with the following code:
async signup(@Body() dto: SignUpDto, @Res() res: Response) {
const result = await this.authService.signup(dto);
// Generate HTML response
const html = `
<html>
<body style="text-aign: center; font-family: Arial. sans-serif;">
<h3>Scan the QR Code to Set Up 2FA</h2>
<img src="${result.image}" alt="QR Code" style="margin-top: 20px" />
</body>
</html>
`;
res.setHeader('Content-Type', 'text/html');
res.status(HttpStatus.CREATED).send(html);
}
Remember to make sure that the
Res
decorator is imported from@nestjs/common
by replacing the first import statement withimport { Body, Controller, HttpCode, HttpStatus, Post, Res } from '@nestjs/common';
Make sure to also importResponse
fromexpress
by adding this import statement:import { Response } from 'express';
.
This method calls the signup
method from the authentication service to register the user and generate a QR code for setting up two-factor authentication. Then, it sends an HTML response displaying the QR code, prompting the user to scan it to enable 2FA.
Next, still in the same class, you need to add an endpoint that will allow the user to validate their OTP. Add the following method to the AuthController
class:
@Post('/two-factor-auth')
@HttpCode(HttpStatus.OK)
twoFactorAuth(@Body() dto: TwoFactorAuthDto) {
return this.authService.twoFactorAuth(dto.userId, dto.token);
}
This method takes the user's ID and token from the request body, validates the token using the authentication service and returns new access tokens if the validation is successful.
Next, you need to define the TwoFactorAuthDto
class. Create a new file named two-factor-auth.dto.ts
in the src/auth/dto
folder and add the code below.
import { IsNotEmpty, IsString } from "class-validator";
export class TwoFactorAuthDto {
@IsString()
@IsNotEmpty()
userId: string;
@IsString()
@IsNotEmpty()
token: string;
}
This class validates that the userId
and token
fields are non-empty strings using class-validator
decorators.
Remember to go back to the
src/auth/auth.controller.ts
file and import the decorator using this import statement:import { TwoFactorAuthDto } from './dto/two-factor-auth.dto';
,
Lastly, open the src/user/user.service.ts
file and add the following method that is used to update a user's totp
value in the database.
async updateUserTOTP(userId: string, totp: string) {
// Retrieve user
const user = await this.findById(userId);
if (!user) {
throw new HttpException(
'User with the provided ID does not exist',
HttpStatus.NOT_FOUND,
);
}
// update user totp
user.totp = totp;
return this.userRepository.save(user);
}
Make sure the HttpException
class and HttpStatus
enum are imported into the file by replacing the first import statement with the following:
import { HttpException, HttpStatus, Injectable } from "@nestjs/common";
Two-factor authentication is now fully set up in your application. You can now go ahead and test it out.
Testing the Application
To test if everything is working as expected, make sure your Docker containers are still running, and then run the NestJS application using the following command:
npm run start:dev
Next, open your Postman application and send a POST request to the "localhost:3000/api/auth/signup" endpoint as you did earlier. Make sure to use different credentials this time. You should get a response prompting you to scan the QR code:
Make sure you select the Preview tab to see the QR code.
Next, scan the QR code using your authenticator app and once it is set successfully, attempt to log in using the same credentials by sending a POST request to the "localhost:3000/api/auth/signin" endpoint. You will be prompted to provide your OTP.
Take note of your user ID from the response and make a POST request to "localhost:3000/api/auth/two-factor-auth" with the following payload:
{
"userId": "<YOUR-USER-ID>",
"token": "<YOUR-TOKEN-FROM-THE-AUTHENTICATOR-APP>"
}
Make sure to replace the placeholder values with the appropriate values.
You should get back response with an access token and your user ID:
This confirms that everything is working as expected.
You can access the full code on GitHub.
Conclusion
As you have seen in this guide, implementing TOTP 2FA in your NestJS application is a straightforward process, thanks to the awesome community packages. You learned how to generate secure TOTP codes, present them as QR codes for users to scan, and validate these codes during subsequent login attempts.
By following these steps, you now have a robust and secure 2FA system that enhances your application’s authentication process. This implementation not only adds an extra layer of security but also demonstrates how modern authentication techniques can be seamlessly integrated into a NestJS application. Feel free to build upon this foundation and adapt it to suit the specific needs of your project!