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:
- Triggers SMS-based login on a demo application.
- Reads the OTP from a dedicated Mailisk phone number with
cy.mailiskSearchSms. - 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_dateimmediately 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.