End-to-end Testing
How-tos & Guides
8 min read

Testing two-factor authentication with Cypress

Learn how to test 2FA workflows, including email or SMS-based authentication, using Cypress.

Kevin Tomas
Published September 9, 2022
AI Assistant for Playwright
Code AI-powered test steps with the free ZeroStep JavaScript library
Learn more

Introduction

Two-factor authentication (2FA) adds an extra layer of security to web applications by verifying a secondary credential beyond the user’s username and password. Common methods for issuing a two-factor challenge include: sending an email, sending an SMS, or requiring the user to enter a “time-based one-time password (TOTP)” via a mobile app like Google Authenticator or Authy.

This article will cover how to automatically test these scenarios using Cypress, with a working code example for testing email-based authentication.

Setting up our demo application

Let’s first have a look at the demo application that we will be testing against. The app is using Next.js as a framework, is written in TypeScript, and uses an email for two-factor authentication. The demo app consists of three routes, which we’ll take a closer look at. The code can be found here.

The first route renders the login page which contains a simple email/password submission form. Here’s the subsequent route that is called after the user clicks the ‘Login’ button.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// pages/api/login.ts
import type { NextApiRequest, NextApiResponse } from "next";
import emailer from "../../mails/emailer";

type Data = {
  success: boolean,
};

export default async function handler(req: NextApiRequest, res: NextApiResponse<Data>) {
  if (req.method === "POST") {
    const email = req.body.email;
    const password = req.body.password;

    // hard coded verfication code for demo
    const verificationCode = "12345";

    if (email === "test@test.com" && password === "test") {
      res.status(200).json({ success: true });

      const info = await emailer.sendMail({
        from: '"Registration system" <test1234@gmail.com>',
        to: email,
        subject: "Confirmation link",
        text: `http://localhost:3000/verification?email=${email}&verificationCode=${verificationCode}`,
        html: `http://localhost:3000/verification?email=${email}&verificationCode=${verificationCode}`,
      });
      return;
    } else {
      res.status(401).json({ success: false });
    }
  }
}

When the correct username and password are entered (and in our sample app we are expecting to receive test@test.com and test, respectively), a 200 response code is returned and an email containing the sender, receiver, subject, text and the html is being sent via the emailer module. We’ll take a closer look at how the email is sent in the next section.

After providing the proper credentials, the user will be redirected to the /confirmation route, where a message is displayed asking the user to check their email. This email will contain a link which will redirect the user to the /verification route. After clicking on the link, the user will see the following message:

The code for the corresponding route looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import type { NextApiRequest, NextApiResponse } from "next";

type Data = {
  success: boolean,
};

export default async function handler(req: NextApiRequest, res: NextApiResponse<Data>) {
  if (req.method === "POST") {
    const email = req.body.email;
    const verificationCode = req.body.verificationCode;

    if (email === "test@test.com" && verificationCode === "12345") {
      res.status(200).json({ success: true });
    } else {
      res.status(401).json({ success: false });
    }
  }
}

Setting up a local SMTP mail server

In order to set up a local SMTP mail server, we will be using the smtp-tester module, which will allow us to accept connections and to receive emails with only a few lines of code. It can be installed via the command npm install smtp-tester. In order to start your mail server locally independently from Cypress, run node mails/mail-server.js in your terminal and the server should log the following output:

This works fine for manually testing this behavior, but for our Cypress tests we’d rather that the SMTP mail server start up automatically before the tests run.

After installing Cypress with npm install cypress --save-dev, a cypress.config.ts file is created on the root level of your project. Inside this config file, you can modify or extend the behavior of Cypress. In our case, we want to launch a local SMTP server with the smtp-tester package and define a task which will handle the incoming emails.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import { defineConfig } from "cypress";
const ms = require("smtp-tester");

interface Email {
  body: string;
  html: string;
  headers: {
    from: string,
    to: string,
  };
}

export default defineConfig({
  e2e: {
    setupNodeEvents(on, config) {
      const port = 7777;
      const mailServer = ms.init(port);
      console.log("mail server at port %d", port);

      let recievedEmail: Email = {
        body: "",
        html: "",
        headers: {
          from: "",
          to: "",
        },
      };

      mailServer.bind((addr: string, id: string, email: Email) => {
        console.log(email.body);

        recievedEmail = {
          body: email.body,
          html: email.html,
          headers: {
            from: email.headers.from,
            to: email.headers.to,
          },
        };
      });

      on("task", {
        getMail() {
          return recievedEmail || null;
        },
      });
    },
  },
});

The code inside the setUpNodeEvents() function executes in a separate Node environment that doesn’t have access to any Cypress or cy commands. After initializing the mail server we store the incoming emails in an object called recievedEmail and return this object inside the newly created task getMail().

The final step here is to implement the code that actually sends the emails to our local SMTP mail server. If you’re in a Node environment, the nodemailer module provides a good solution for sending emails. Nodemailer can be install by running the following command:

1
npm install nodemailer

Below you can find the code for setting up the transport of our emails, which we already used in the login route in order to send an email when the correct credentials are entered:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// mails/emailer.js
import nodemailer from "nodemailer";

const host = "localhost";
const port = 7777;

const transporter = nodemailer.createTransport({
  host,
  port,
  secure: port === 465,
});

module.exports = transporter;

In our case, we only need to provide some general options for successfully connecting to the mail server. In the last line, we export the transporter in order to use it in our login api route.

Testing email-based 2FA using Cypress

Now that the set up of the email server, demo app, and nodemailer transport is complete, we can now write our Cypress test for verifying the 2FA workflow end-to-end:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// cypress/e2e2/whole_flow.cy.js

describe("Whole Authentification Flow", () => {
  beforeEach(() => {
    cy.visit("http://localhost:3000/");
  });

  it("should successfully verify with correct credentials and correct url parameters", () => {
    const email = "test@test.com";
    const password = "test";

    cy.intercept("POST", "/api/login").as("login");

    cy.get('input[id="email"]').type(email);
    cy.get('input[id="password"]').type(password);
    cy.get('button[type="submit"]').click();

    cy.url().should("include", "/confirmation");

    cy.wait("@login").its("request.body").should("deep.equal", {
      email,
      password,
    });

    cy.get("@login").its("response.body").should("deep.equal", {
      success: true,
    });

    cy.get("p[id=error]").should("not.exist");

    cy.task("getMail").then((mail) => {
      cy.intercept("POST", "/api/verify").as("verify");

      const recievedUrl = mail.body;

      cy.visit(recievedUrl);

      cy.wait("@verify").its("request.body").should("deep.equal", {
        email: "test@test.com",
        verificationCode: "12345",
      });

      cy.get("@verify").its("response.body").should("deep.equal", {
        success: true,
      });

      cy.get('p[id="success"]').should("be.visible");
    });
  });
});

In this test we use the cy.intercept() command to spy on certain network responses and requests and get access to their contents. In our case we expect the login request to contain the email and password entered by the user, and the response to contain an object indicating the request was successful.

Next, we call the getMail task, which we defined earlier in the cypress.config.ts file, to retrieve the email sent to our SMTP server. After retrieving the email, we parse the email body to extract the verification link that points to the /verification route in our sample. Next, we navigate to that URL using the cy.visit command which simulates an end-user clicking on the link within their email client. Finally, we check the subsequent API response to validate that user successfully validated their email address.

Testing SMS-based 2FA using Twilio and Cypress

Twilio is a service which allows developers to implement a 2FA involving sending a SMS. With the help of Twilio you can programmatically receive and send calls and text messages. In order to add Twilio to your project, run npm install twilio in your terminal.

The process of validating SMS is very similar to the email example above. Instead of launching a local SMTP mail server, you would call Twilio’s API from inside your test. After the text message is sent by the application under test, you would invoke Twilio’s API to catch the contents of the text message and use this information for further testing. Check out Twilio’s docs for more details.

Testing TOTP-based 2FA using Cypress with the cypress-otp plugin

There is a plugin called cypress-otp which is suitable for testing 2FA scenarios using TOTP. You can add it to your project with the command npm install -D cypress-otp. In order to use the package’s functionality inside your cypress tests, you will again have to register a task inside the cypress.config.ts file:

1
2
3
4
5
on("task", {
  generateOTP() {
    require("cypress-otp");
  },
});

After doing so, you can use this task in your Cypress test with this exemplary code:

1
2
3
cy.task("generateOTP", "YOUR_SECRET").then((token) => {
  cy.get("#otp-token").type(token);
});

Conclusion

After reading this article, you should have all the information you need to create Cypress tests that have 2FA functionality! You can see the full source code for these examples at this GitHub repository.

Reflect: A testing tool with built-in support for 2FA testing

Reflect is a no-code testing tool that can test virtually any action you can take on a browser, including advanced scenarios like 2FA authentication. With Reflect, you can test workflows that generate and validate emails and SMS messages without having to install any external software or manage your own test infrastructure.

Creating a test in Reflect is easy: the tool records your actions as you use your site and automatically translates those actions into a repeatable test that you can run any time.

Get automated test coverage for your app today — try it for free.

Get started with Reflect today

Create your first test in 2 minutes, no installation or setup required. Accelerate your testing efforts with fast and maintainable test suites without writing a line of code.

Copyright © Reflect Software Inc. All Rights Reserved.