April 6, 2026

SMS verification with Cypress

Most login forms rely on SMS to deliver short-lived one-time passwords (OTPs). If that SMS never arrives or contains the wrong content, people get locked out of their accounts and support tickets start piling up.

Automating this flow is the only way to know it keeps working. With a repeatable test that requests a code, reads the SMS, and confirms the login succeeds, you can catch delivery and verification issues before they cause problems.

We'll pair Cypress with Mailisk's SMS testing features. We'll run an end-to-end Cypress test that:

  1. Triggers SMS-based login on a demo application.
  2. Reads the OTP from a dedicated Mailisk phone number with cy.mailiskSearchSms.
  3. Submits the code back in the UI and confirms the user reaches the dashboard.

For command-level details and the latest examples, see the official cypress-mailisk repository.

Cypress

We'll reuse the same Cypress setup you might already have for UI tests. If you're new to Cypress, take a minute to browse the official docs; the sample flow in this post assumes Cypress is already configured.

Mailisk

For SMS we need two things from Mailisk:

  • An API key.
  • A dedicated phone number that can receive login codes.

You can request a number from the Mailisk dashboard. Once approved, you'll see it listed with its status.

Install the Cypress plugin:

npm install --save-dev cypress-mailisk

Then load the commands in cypress/support/e2e.js:

import "cypress-mailisk";

The plugin exposes cy.mailiskSearchSms(phoneNumber, params?, requestOptions?). By default it waits until at least one SMS matches, applies a five-minute timeout, and ignores SMS older than 15 minutes. We'll rely on that built-in polling rather than writing our own retry loop.

Getting started

Add your Mailisk credentials to Cypress env config (for example in cypress.config.js):

module.exports = defineConfig({
  env: {
    MAILISK_API_KEY: "<your-api-key>",
    MAILISK_SMS_NUMBER: "<verified-mailisk-phone-number>",
  },
});

Writing the test

We'll keep the spec in sms-verification.cy.js. The target flow is:

  • Go to login page.
  • Enter the dedicated phone number.
  • Wait for the SMS code to land in Mailisk.
  • Parse the OTP.
  • Submit it and ensure the user ends up on the dashboard.

Avoid running multiple SMS tests in parallel against the same phone number unless your filters make each message unambiguous.

To avoid picking up old messages when runs happen close together, set from_date right before triggering the SMS:

describe("SMS verification", () => {
  const smsNumber = Cypress.env("MAILISK_SMS_NUMBER");

  it("logs a user in via OTP", () => {
    expect(smsNumber, "MAILISK_SMS_NUMBER").to.be.a("string").and.not.be.empty;

    cy.visit("http://localhost:3000/login");

    // Capture a lower bound so old SMS messages are ignored.
    const searchStart = new Date();

    cy.contains("Use SMS instead").click();
    cy.get("#phone").type(smsNumber);
    cy.get('form button[type="submit"]').click();
    cy.location("pathname").should("eq", "/login/verify");

    cy.mailiskSearchSms(
      smsNumber,
      {
        body: "Your security code",
        from_date: searchStart,
      },
      {
        timeout: 1000 * 120,
      },
    ).then(({ data }) => {
      expect(data).to.not.be.empty;
      const latest = data[0];
      const otp = latest.body.match(/(\d{6})/)?.[1];
      expect(otp, "OTP should be present in SMS body").to.not.be.undefined;

      cy.get("#code").type(otp || "");
      cy.get('form button[type="submit"]').click();
      cy.location("pathname").should("eq", "/dashboard");
    });
  });
});

The cy.mailiskSearchSms call mirrors the official plugin docs. We scope the request with body plus from_date captured before sending the OTP, so we don't accidentally pick up an older message. Since the command already long-polls, the test simply waits until Mailisk returns a matching SMS.

Extracting the OTP

const searchStart = new Date();

cy.mailiskSearchSms(
  smsNumber,
  {
    body: "Your security code",
    from_date: searchStart,
  },
  {
    timeout: 1000 * 120,
  },
).then(({ data }) => {
  expect(data).to.not.be.empty;
  const latest = data[0];
  const otp = latest.body.match(/(\d{6})/)?.[1];
  expect(otp).to.not.be.undefined;
});

Mailisk stores the full SMS body, so extracting six digits is usually just a regex. You can add from_number if you also need to pin the sender.

Tip: Capture from_date immediately before clicking "Send code". This scopes the search to the current run and helps prevent false positives.

Finish by submitting the OTP and optionally logging out/in again to prove the session is fully verified.

Final spec

Here's the full sms-verification.cy.js for easy reference:

describe("SMS verification", () => {
  const smsNumber = Cypress.env("MAILISK_SMS_NUMBER");

  it("logs a user in via OTP", () => {
    expect(smsNumber, "MAILISK_SMS_NUMBER").to.be.a("string").and.not.be.empty;

    cy.visit("http://localhost:3000/login");
    const searchStart = new Date();

    cy.contains("Use SMS instead").click();
    cy.get("#phone").type(smsNumber);
    cy.get('form button[type="submit"]').click();
    cy.location("pathname").should("eq", "/login/verify");

    cy.mailiskSearchSms(smsNumber, {
      body: "Your security code",
      from_date: searchStart,
    }).then(({ data }) => {
      expect(data).to.not.be.empty;
      const latest = data[0];
      const otp = latest.body.match(/(\d{6})/)?.[1];
      expect(otp, "OTP should be present in SMS body").to.not.be.undefined;

      cy.get("#code").type(otp || "");
      cy.get('form button[type="submit"]').click();
      cy.location("pathname").should("eq", "/dashboard");
    });
  });
});

With this in place you now have a Cypress spec that continuously validates your SMS login flow. Regressions in SMS delivery, message content, or verification logic will get caught during CI instead of production.

Ready to start testing emails?
Create a free account.

Get started