How to Test SSO and Magic Link Logins in Automated Tests (Playwright and Cypress)

TL;DR: To test an SSO login in an automated suite, point your app’s staging environment at a test identity provider that speaks OIDC, complete the redirect in the browser, and assert the authenticated state. To test a magic link, send it to a private test domain, fetch the message through an API, pull the URL out of the body, and navigate straight to it. Both patterns work in Playwright, Cypress, Selenium, and Puppeteer — only the syntax changes.

Passwordless authentication moved the hardest part of your login flow outside your application. An SSO button hands the user to a provider you do not control. A magic link hands them to an inbox you do not control. Either way, the test that used to be “fill in a password, click submit” now spans two systems, and most teams respond by skipping it — seeding a session cookie, stubbing the callback, or marking the whole suite describe.skip. That leaves the single most critical path in the product untested.

You do not have to choose between a stubbed login and a flaky one. Give your tests an identity provider built for testing and a mailbox they can read over an API, and the flow becomes as deterministic as any other assertion. Mailinator’s Universal IDP provides the first; a private testing domain provides the second, and the free Verified Pro tier includes it.

SSO and magic link logins are hard to automate because authentication happens outside the application under test. The credential is issued by a third party, arrives asynchronously, and is single-use. Tests cannot know the token in advance, cannot reuse it across runs, and cannot log into a production identity provider without real accounts and real secrets.

Three specific problems show up in every suite:

  • You need accounts you cannot create. Testing against a real Okta, Entra ID, or Auth0 tenant means provisioning users, storing credentials in CI, and fighting the MFA prompt the provider adds for a login from a new datacenter IP.
  • The credential is ephemeral. A magic link is valid once and expires in minutes. If your test polls too slowly or two runs share an inbox, the link is dead before it is clicked.
  • The flow crosses origins. The browser leaves your domain, authenticates elsewhere, and comes back with a code. Any test tool that cannot follow that redirect chain cannot test the flow at all.

What are the options for testing a login you do not control?

There are three approaches, and they trade realism against speed. Stubbing the callback is fastest but tests nothing about your integration. A real identity provider is fully realistic but slow and account-bound. A test identity provider sits in between: a real OIDC handshake against a provider built to be logged into by a script.

ApproachWhat it verifiesCostBest for
Seed the session cookieNothing about auth — skips the flowNear zeroTests where login is setup, not the subject
Stub the OIDC callbackYour callback handler onlyLowUnit and component tests
Test identity providerFull redirect, real signed JWT, your token validationLowE2E and CI suites
Real production IdPEverything, including provider configHigh — accounts, secrets, MFAA small pre-release smoke suite
Magic link via test inboxEmail delivery, link generation, token consumptionLowAny passwordless flow

The practical answer for most teams is the third row for SSO and the last row for magic links, with one or two tests against the real provider before a release.

How do you test an SSO login with a test identity provider?

Point your staging environment’s OIDC client at a test identity provider’s discovery document, and your existing auth library configures itself. Mailinator’s Universal IDP is a standards-compliant OIDC provider where the “password” is simply an inbox name — so any test can log in as any user without an account existing beforehand.

The discovery endpoint is:

https://idp.mailinator.com/idp/.well-known/openid-configuration

Point passport-openidconnect, Authlib, next-auth, Spring Security, or any OIDC library at that URL and it will discover the rest. If you are configuring endpoints by hand:

EndpointURL
Issuerhttps://idp.mailinator.com
Authorizationhttps://idp.mailinator.com/idp/authorize
Tokenhttps://idp.mailinator.com/idp/token
JWKShttps://idp.mailinator.com/idp/jwks.json

Three details matter when you wire it up:

  • It is OIDC, not SAML. The Universal IDP implements the OIDC Authorization Code flow. If your application only speaks SAML, this pattern does not apply — use a SAML-specific test IdP instead.
  • ID tokens are RS256-signed and verified against the published JWKS endpoint, so your token validation code is genuinely exercised rather than bypassed.
  • There is no userinfo endpoint. Claims — including email and email_verified — come from the ID token itself.

The token your app receives looks like this:

{
  "iss": "https://idp.mailinator.com",
  "sub": "qa-signup-1724630400@yourteam.testinator.com",
  "aud": "YourStagingApp",
  "email": "qa-signup-1724630400@yourteam.testinator.com",
  "email_verified": true,
  "nonce": "xyz789",
  "iat": 1724630400,
  "exp": 1724634000
}

On a Mailinator plan with a private domain, authenticate the IdP request with your API token and it issues identities at your own domain — @yourteam.testinator.com instead of @mailinator.com. That matters more than it sounds: applications routinely reject known disposable domains at signup, which is the same reason test emails get blocked. A private domain looks like any ordinary company domain, and the identity it issues shares the inbox your email assertions read from.

How do you automate an SSO login in Playwright?

In Playwright, drive the SSO flow like any other navigation: click the SSO button, wait for the redirect to the identity provider, enter an inbox name, and assert the authenticated state back on your application. Use waitForURL to follow the cross-origin hop rather than a fixed timeout.

import { test, expect } from '@playwright/test';

const DOMAIN = process.env.MAILINATOR_DOMAIN;   // yourteam.testinator.com

test('user signs in through SSO', async ({ page }) => {
  const inbox = `sso-${Date.now()}`;

  await page.goto('https://staging.yourapp.com/login');
  await page.getByRole('button', { name: /sign in with sso/i }).click();

  // Cross-origin hop to the identity provider
  await page.waitForURL(/idp\.mailinator\.com/);

  // The IdP login page asks for one thing: an inbox name.
  // A role-based locator survives markup changes better than a CSS selector.
  await page.getByRole('textbox').first().fill(inbox);
  await page.getByRole('button', { name: /log ?in|sign ?in|continue/i }).click();

  // Back on your app, authenticated as that identity
  await page.waitForURL(/staging\.yourapp\.com/);
  await expect(page.getByText(`${inbox}@${DOMAIN}`)).toBeVisible();
});

Run this once in a setup project and save the result with storageState, and every other test in the suite starts authenticated without repeating the handshake:

// auth.setup.js
import { test as setup } from '@playwright/test';

setup('authenticate once', async ({ page }) => {
  // ...the SSO flow above...
  await page.context().storageState({ path: 'playwright/.auth/user.json' });
});

This is the difference between an SSO suite that adds four seconds to the run and one that adds four seconds to every test.

Testing a magic link takes four steps: request the link from a unique address on your private test domain, poll the inbox API until the email arrives, extract the URL with a regular expression, then navigate to it and assert the session. Because the link is single-use, every test run needs its own address.

  1. Generate a unique address — login-{runId}@yourteam.testinator.com.
  2. Submit it to your login form so the application sends the magic link.
  3. Fetch the newest message for that inbox from the API and pull out the full message body.
  4. Extract the URL, navigate to it, and assert the authenticated state.

The two Mailinator endpoints you need are the same ones used for CI/CD email testing:

GET https://api.mailinator.com/api/v2/domains/{domain}/inboxes/{inbox}
GET https://api.mailinator.com/api/v2/domains/{domain}/inboxes/{inbox}/messages/{message_id}

The first returns message summaries in msgs; the second returns the full body in parts. Pass your team API token in the Authorization header.

In Cypress, use cy.request() to poll the inbox API with a recursive custom command, extract the link with a regular expression scoped to your own auth path, and hand it to cy.visit(). Keep the API token in Cypress.env(), never in the spec file.

Cypress.Commands.add('getMagicLink', (inbox, attempt = 0) => {
  const domain = Cypress.env('MAILINATOR_DOMAIN');
  const token = Cypress.env('MAILINATOR_API_TOKEN');

  return cy.request({
    url: `https://api.mailinator.com/api/v2/domains/${domain}/inboxes/${inbox}`,
    headers: { Authorization: token },
  }).then((res) => {
    const summary = res.body.msgs?.[0];

    if (summary) {
      return cy.request({
        url: `https://api.mailinator.com/api/v2/domains/${domain}/inboxes/${inbox}/messages/${summary.id}`,
        headers: { Authorization: token },
      }).then((full) => {
        const body = full.body.parts.map((part) => part.body).join(' ');
        // Scope the pattern to your own auth path so it can't match a footer link
        const match = body.match(/https:\/\/staging\.yourapp\.com\/auth\/verify\?token=[A-Za-z0-9._-]+/);
        if (match) return match[0];
        throw new Error('Email arrived but contained no magic link');
      });
    }

    if (attempt > 9) throw new Error('Magic link email not received');
    cy.wait(2000);
    return cy.getMagicLink(inbox, attempt + 1);
  });
});

it('logs in with a magic link', () => {
  const inbox = `login-${Date.now()}`;

  cy.visit('https://staging.yourapp.com/login');
  cy.get('#email').type(`${inbox}@${Cypress.env('MAILINATOR_DOMAIN')}`);
  cy.get('#send-link').click();

  cy.getMagicLink(inbox).then((link) => {
    cy.visit(link);
    cy.contains('Signed in').should('be.visible');
  });
});

The same recursive-polling shape works for password resets and email verification — see testing emails in Cypress for the general pattern, and OTP and 2FA testing when the credential is a code rather than a link.

Stable authentication tests share five habits: isolate every run on its own address, poll with a timeout rather than a fixed wait, authenticate once and reuse the session, scope extraction patterns tightly, and keep tokens in environment variables. Most flakiness in passwordless suites traces back to two runs competing for one inbox.

  • One address per run. login-${Date.now()} or your CI run ID. Shared inboxes in parallel CI produce tests that pass alone and fail together.
  • Poll, don’t sleep. A retry loop with a deadline absorbs delivery latency; cy.wait(5000) either wastes five seconds or is not enough.
  • Reuse the session. Playwright’s storageState or a Cypress session command keeps the handshake out of every test.
  • Scope your regex to your own domain and path. An unanchored URL pattern will happily match the unsubscribe link in the footer.
  • Never hard-code the API token. Use process.env or Cypress.env(), sourced from your CI secret store.
  • Prefer a webhook to polling where your platform supports it — it removes the delivery-latency variable entirely.
  • Clean up. Mailinator’s API accepts a delete parameter on message retrieval so test messages do not accumulate.

Frequently asked questions

How do you test SSO login without a real identity provider?

Use a test identity provider that implements OIDC, such as Mailinator’s Universal IDP. Your application performs a real authorization code exchange and validates a real RS256-signed token, but the login itself requires only an inbox name — no user account, tenant configuration, or stored credentials.

Can you test SAML SSO the same way?

Not with an OIDC provider. The Universal IDP implements OpenID Connect only. If your application consumes SAML assertions, you need a SAML-specific test IdP; the surrounding patterns in this guide — unique identities per run, session reuse, asserting on the authenticated state — still apply.

Usually because the test polls on a fixed sleep that is shorter than delivery time, or because two parallel runs share an inbox and one consumes the other’s single-use link. Give every run its own address on a private domain and replace fixed waits with a retry loop that has a deadline.

Can Playwright and Cypress follow the cross-origin SSO redirect?

Yes. Playwright handles cross-origin navigation natively — use waitForURL to wait for the identity provider and again for the return trip. Cypress supports it through cy.origin(), or you can bypass the UI hop entirely by completing the token exchange with cy.request() and setting the resulting session.

What is the difference between a public and a private test identity?

A public identity issues tokens for @mailinator.com addresses, which many applications reject as a known disposable domain. A private identity issues tokens at your own domain — @yourteam.testinator.com — which passes signup validation, keeps test data visible only to your team, and shares an inbox with your email assertions.

Want an identity provider and an inbox your tests can both read? Start a free Verified Pro account, point your staging OIDC client at the discovery endpoint, and automate SSO and magic link logins end to end.

Leave a comment

Your email address will not be published. Required fields are marked *