Resource library

QA How-To

Test Email Magic Link Expiration With Playwright (2026)

Learn how to test email magic link expiration playwright flows with a deterministic server clock, secure token checks, boundary cases, and CI-ready tests.

25 min read | 2,150 words

TL;DR

Inject or control the server clock, request the link through the UI, retrieve it from a test mail adapter, and open it at expiresAt - 1, expiresAt, and expiresAt + 1. Add resend, replay, and token-tampering checks without waiting for real time.

Key Takeaways

  • Control the backend time source because browser Clock does not change server time.
  • Define the exact rule as valid before expiresAt and expired at or after expiresAt.
  • Test one millisecond before, exactly at, and one millisecond after the boundary.
  • Retrieve links through an isolated test outbox or sandbox provider API.
  • Cover resend, single use, replay, and tampering in addition to expiration.
  • Store token digests and keep raw magic URLs out of durable logs and artifacts.
  • Partition clocks and outboxes before enabling parallel Playwright workers.

To test email magic link expiration playwright flows reliably, move the backend clock to known instants and assert what the browser shows after it opens the link. Do not wait ten real minutes, change the CI machine clock, or assume Playwright's browser Clock changes server time. Expiration is a server authorization decision, so the test must control the time source used by the verifier.

This tutorial builds a small TypeScript authentication service, a test-only mail outbox, and a Playwright suite that covers a valid link, the exact expiry boundary, an already expired link, replay, resend, and token tampering. You can copy the same design into a real application by injecting a clock and replacing the outbox adapter with your sandbox mail provider.

The most important contract is explicit: the link is valid while now < expiresAt and expired when now >= expiresAt. One millisecond on either side gives you more evidence than a long sleep because it proves the comparison operator as well as the user experience.

TL;DR

Concern Reliable test choice Avoid
Server expiration Inject a server clock and move it to exact instants page.clock alone
Email delivery Read a test-only outbox or provider API A personal mailbox
Boundary coverage Test expiresAt - 1, expiresAt, and expiresAt + 1 Waiting for a rounded minute
Token safety Store a SHA-256 digest and expose the raw token only in the test adapter Logging production tokens
Link reuse Assert the first visit succeeds and the second fails Treating expiry as the only invalid state
CI diagnosis Keep traces on retry and attach safe timestamps Printing magic URLs in shared logs

What You Will Build

You will create:

  • A Node.js HTTP service with a login form and a POST /api/auth/magic-links endpoint.
  • A SHA-256 token store whose verifier enforces now >= expiresAt.
  • Test-only reset, clock, and mail-outbox endpoints that exist only in the fixture service.
  • Playwright helpers that request a link through the UI and retrieve it through APIRequestContext.
  • Boundary, expired-link, resend, replay, and tampering tests.
  • A GitHub Actions job that installs one browser and preserves traces on retry.

Prerequisites

Use these exact tutorial versions:

  • Node.js 24.18.0 LTS.
  • npm 11.x, as bundled or supported by your Node installation.
  • @playwright/test 1.61.1.
  • TypeScript 5.9.3.
  • tsx 4.22.4.
  • @types/node 24.13.3.
  • GitHub Actions actions/checkout@v4 and actions/setup-node@v4 for the CI step.

Check the runtime before creating files:

node --version
npm --version

Expected output begins with v24.18.0 and 11.. For a broader project layout before you continue, review the Playwright TypeScript framework tutorial.

Step 1: Create the Playwright Project

Make an empty folder, initialize npm, and install the pinned development dependencies:

mkdir magic-link-expiration
cd magic-link-expiration
npm init -y
npm install --save-dev @playwright/test@1.61.1 typescript@5.9.3 tsx@4.22.4 @types/node@24.13.3
npx playwright install chromium

Replace package.json with this minimal manifest:

{
  "name": "magic-link-expiration",
  "private": true,
  "type": "module",
  "scripts": {
    "test": "playwright test",
    "test:magic-link": "playwright test tests/magic-link.spec.ts",
    "typecheck": "tsc --noEmit"
  },
  "devDependencies": {
    "@playwright/test": "1.61.1",
    "@types/node": "24.13.3",
    "tsx": "4.22.4",
    "typescript": "5.9.3"
  }
}

Add tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2023",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noEmit": true,
    "types": ["node", "@playwright/test"]
  },
  "include": ["playwright.config.ts", "tests/**/*.ts"]
}

Verify Step 1: run npx playwright --version. It should print Version 1.61.1.

Step 2: Build a Controllable Magic Link Service

Create tests/fixtures/auth-server.ts. ```ts import { createHash, randomBytes } from 'node:crypto'; import { createServer, type IncomingMessage, type ServerResponse, } from 'node:http';

const PORT = 4173; const ORIGIN = 'http://127.0.0.1:' + PORT; const LINK_TTL_MS = 10 * 60_000; const DEFAULT_NOW = Date.parse('2026-08-06T10:00:00.000Z');

type LinkRecord = { email: string; tokenDigest: string; expiresAt: number; consumedAt: number | null; };

type MailRecord = { to: string; magicUrl: string; expiresAt: number; };

let controlledNow = DEFAULT_NOW; const links = new Map<string, LinkRecord>(); const outbox: MailRecord[] = [];

function digest(token: string): string { return createHash('sha256').update(token).digest('hex'); }

function now(): number { return controlledNow; }

function json( response: ServerResponse, status: number, value: unknown, ): void { response.writeHead(status, { 'content-type': 'application/json' }); response.end(JSON.stringify(value)); }

function redirect( response: ServerResponse, location: string, cookie?: string, ): void { const headers: Record<string, string> = { location }; if (cookie) headers['set-cookie'] = cookie; response.writeHead(302, headers); response.end(); }

async function readJson(request: IncomingMessage): Promise { const chunks: Buffer[] = []; for await (const chunk of request) chunks.push(Buffer.from(chunk)); return JSON.parse(Buffer.concat(chunks).toString('utf8')) as T; }

function loginPage(error: string | null): string { const messages: Record<string, string> = { expired: 'This sign-in link has expired. Request a new one.', used: 'This sign-in link has already been used.', invalid: 'This sign-in link is invalid.', }; const alert = error ? messages[error] ?? messages.invalid : '';

return '' + '

Sign in

' + '
' + '
' + '

' + alert + '

' + ''; }

async function route( request: IncomingMessage, response: ServerResponse, ): Promise { const url = new URL(request.url ?? '/', ORIGIN);

if (request.method === 'GET' && url.pathname === '/health') { json(response, 200, { ok: true }); return; }

if (request.method === 'GET' && url.pathname === '/login') { response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); response.end(loginPage(url.searchParams.get('error'))); return; }

if (request.method === 'POST' && url.pathname === '/api/auth/magic-links') { const { email } = await readJson<{ email: string }>(request); if (typeof email === 'string' && email.includes('@')) { const token = randomBytes(32).toString('base64url'); const tokenDigest = digest(token); const expiresAt = now() + LINK_TTL_MS; links.set(tokenDigest, { email, tokenDigest, expiresAt, consumedAt: null, }); outbox.push({ to: email, magicUrl: ORIGIN + '/auth/verify?token=' + encodeURIComponent(token), expiresAt, }); } json(response, 202, { message: 'Check your email' }); return; }

if (request.method === 'GET' && url.pathname === '/auth/verify') { const token = url.searchParams.get('token') ?? ''; const record = links.get(digest(token)); if (!record) { redirect(response, '/login?error=invalid'); } else if (record.consumedAt !== null) { redirect(response, '/login?error=used'); } else if (now() >= record.expiresAt) { redirect(response, '/login?error=expired'); } else { record.consumedAt = now(); const session = randomBytes(24).toString('base64url'); redirect( response, '/account', 'session=' + session + '; HttpOnly; SameSite=Lax; Path=/', ); } return; }

if (request.method === 'GET' && url.pathname === '/account') { const authenticated = request.headers.cookie?.includes('session='); if (!authenticated) { redirect(response, '/login'); return; } response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); response.end('

Your account

Signed in

'); return; }

if (request.method === 'POST' && url.pathname === '/__test/reset') { const { now: nextNow } = await readJson<{ now: number }>(request); controlledNow = nextNow; links.clear(); outbox.length = 0; json(response, 200, { now: controlledNow }); return; }

if (request.method === 'POST' && url.pathname === '/__test/clock') { const { now: nextNow } = await readJson<{ now: number }>(request); controlledNow = nextNow; json(response, 200, { now: controlledNow }); return; }

if (request.method === 'GET' && url.pathname === '/__test/emails/latest') { const to = url.searchParams.get('to'); const mail = [...outbox].reverse().find((item) => item.to === to); if (!mail) { json(response, 404, { message: 'No email found' }); return; } json(response, 200, mail); return; }

json(response, 404, { message: 'Not found' }); }

createServer((request, response) => { void route(request, response).catch((error: unknown) => { console.error(error); if (!response.headersSent) json(response, 500, { message: 'Error' }); else response.end(); }); }).listen(PORT, '127.0.0.1', () => { console.log('Magic link fixture listening at ' + ORIGIN); });


The verifier hashes the submitted token, checks single-use state, evaluates the injected clock, and creates a session only for a fresh record. The three `__test` routes make state observable and controllable without weakening a deployed service.

**Verify Step 2:** run `npx tsx tests/fixtures/auth-server.ts` in one terminal. In another terminal, run `curl http://127.0.0.1:4173/health`. The response must be `{"ok":true}`. Stop the manual server before the next step so Playwright can own its lifecycle.

## Step 3: Let Playwright Start the Service

Create `playwright.config.ts`:

```ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  fullyParallel: false,
  workers: 1,
  retries: process.env.CI ? 1 : 0,
  reporter: process.env.CI ? 'github' : 'list',
  use: {
    baseURL: 'http://127.0.0.1:4173',
    trace: 'on-first-retry',
  },
  webServer: {
    command: 'npx tsx tests/fixtures/auth-server.ts',
    url: 'http://127.0.0.1:4173/health',
    reuseExistingServer: !process.env.CI,
    timeout: 30_000,
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
  ],
});

The configuration lets Playwright own the fixture process and waits for its health route before tests begin. One worker protects the shared clock and outbox. The Playwright APIRequestContext examples explain the API layer used next.

Verify Step 3: run npx playwright test --list --pass-with-no-tests. The command should load the configuration, report zero tests, and exit successfully because the first spec is added after the helper.

Step 4: Add Mail and Clock Helpers

Create tests/magic-link.helpers.ts:

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

export const BASE_TIME = Date.parse('2026-08-06T10:00:00.000Z');

export type MagicEmail = {
  to: string;
  magicUrl: string;
  expiresAt: number;
};

export async function resetScenario(
  request: APIRequestContext,
  now = BASE_TIME,
): Promise<void> {
  const response = await request.post('/__test/reset', {
    data: { now },
  });
  expect(response.ok()).toBeTruthy();
}

export async function setServerTime(
  request: APIRequestContext,
  now: number,
): Promise<void> {
  const response = await request.post('/__test/clock', {
    data: { now },
  });
  expect(response.ok()).toBeTruthy();
}

export async function requestMagicLink(
  page: Page,
  request: APIRequestContext,
  email: string,
): Promise<MagicEmail> {
  await page.goto('/login');
  await page.getByLabel('Email').fill(email);
  await page.getByRole('button', { name: 'Send magic link' }).click();
  await expect(page.getByRole('status')).toHaveText('Check your email');

  const path = '/__test/emails/latest?to=' + encodeURIComponent(email);
  await expect.poll(async () => (await request.get(path)).status())
    .toBe(200);

  const response = await request.get(path);
  return await response.json() as MagicEmail;
}

The browser performs the send action, while the request fixture polls only the isolated mail channel. Resetting before each case prevents a prior recipient or timestamp from satisfying a later assertion.

Verify Step 4: run npm run typecheck. Expect exit code 0. A complaint about APIRequestContext or Page usually means the Playwright package version and lockfile are out of sync.

Step 5: Run test email magic link expiration playwright Boundary Checks

Create tests/magic-link.spec.ts with the core before, exact, and after cases:

import { test, expect } from '@playwright/test';
import {
  BASE_TIME,
  requestMagicLink,
  resetScenario,
  setServerTime,
} from './magic-link.helpers.js';

test.describe('magic link expiration', () => {
  test.beforeEach(async ({ request }) => {
    await resetScenario(request);
  });

  test('accepts the link one millisecond before expiry', async ({
    page,
    request,
  }) => {
    const mail = await requestMagicLink(
      page,
      request,
      'before@example.test',
    );
    await setServerTime(request, mail.expiresAt - 1);

    await page.goto(mail.magicUrl);

    await expect(page).toHaveURL(/\/account$/);
    await expect(
      page.getByRole('heading', { name: 'Your account' }),
    ).toBeVisible();
  });

  test('rejects the link at the exact expiry instant', async ({
    page,
    request,
  }) => {
    const mail = await requestMagicLink(
      page,
      request,
      'exact@example.test',
    );
    await setServerTime(request, mail.expiresAt);

    await page.goto(mail.magicUrl);

    await expect(page).toHaveURL(/\/login\?error=expired$/);
    await expect(page.getByRole('alert')).toHaveText(
      'This sign-in link has expired. Request a new one.',
    );
  });

  test('rejects the link after expiry', async ({ page, request }) => {
    const mail = await requestMagicLink(
      page,
      request,
      'after@example.test',
    );
    await setServerTime(request, mail.expiresAt + 1);

    await page.goto(mail.magicUrl);

    await expect(page.getByRole('alert')).toContainText('expired');
  });
});

The browser Clock API is not used here because the decision lives in the Node service. page.clock.setFixedTime() would affect Date inside the browser context, but the request would still reach a server whose clock had not moved. Use the Playwright Clock API guide when a countdown or client-rendered label also needs controlled browser time.

Verify Step 5: run npm run test:magic-link -- --grep "millisecond|exact expiry|after expiry". Expect three passing Chromium tests and no real ten-minute delay.

Step 6: Expand test email magic link expiration playwright Recovery Coverage

Expiration handling is incomplete unless the user can request a replacement. Add this test inside the existing test.describe block:

test('expires the old link but accepts a newly requested link', async ({
  page,
  request,
}) => {
  const first = await requestMagicLink(
    page,
    request,
    'resend@example.test',
  );
  await setServerTime(request, first.expiresAt);

  await page.goto(first.magicUrl);
  await expect(page.getByRole('alert')).toHaveText(
    'This sign-in link has expired. Request a new one.',
  );

  const replacement = await requestMagicLink(
    page,
    request,
    'resend@example.test',
  );
  expect(replacement.magicUrl).not.toBe(first.magicUrl);
  expect(replacement.expiresAt).toBeGreaterThan(first.expiresAt);

  await page.goto(replacement.magicUrl);
  await expect(page).toHaveURL(/\/account$/);
});

The replacement is issued at the advanced time, so it must receive a different token and a later expiry. The final navigation proves recovery, not merely email generation.

Verify Step 6: run npx playwright test tests/magic-link.spec.ts --grep "newly requested". Expect one pass. If expiresAt values are equal, confirm you advanced to the old boundary before requesting the replacement.

Step 7: Test Replay and Token Tampering

A link can be fresh but still invalid. Add two more tests inside the same describe block:

test('allows one use and rejects a replay', async ({ page, request }) => {
  const mail = await requestMagicLink(
    page,
    request,
    'replay@example.test',
  );

  await page.goto(mail.magicUrl);
  await expect(page).toHaveURL(/\/account$/);

  await page.goto(mail.magicUrl);
  await expect(page).toHaveURL(/\/login\?error=used$/);
  await expect(page.getByRole('alert')).toHaveText(
    'This sign-in link has already been used.',
  );
});

test('rejects a token changed by one character', async ({
  page,
  request,
}) => {
  const mail = await requestMagicLink(
    page,
    request,
    'tamper@example.test',
  );
  const changed = new URL(mail.magicUrl);
  const token = changed.searchParams.get('token') ?? '';
  const suffix = token.endsWith('A') ? 'B' : 'A';
  changed.searchParams.set('token', token.slice(0, -1) + suffix);

  await page.goto(changed.toString());

  await expect(page).toHaveURL(/\/login\?error=invalid$/);
  await expect(page.getByRole('alert')).toHaveText(
    'This sign-in link is invalid.',
  );
});

The first test distinguishes consumption from expiration. The second changes one Base64URL character and proves that no near-match or malformed lookup creates a session.

Verify Step 7: run npm run test:magic-link -- --grep "replay|one character". Expect two passes. If the replay remains on /account, confirm the verifier writes consumption before issuing the redirect.

Step 8: Run the Suite in CI

Create .github/workflows/magic-link.yml in the tutorial project:

name: Magic link expiration

on:
  pull_request:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24.18.0
          cache: npm
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: npx playwright test tests/magic-link.spec.ts
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: |
            playwright-report/
            test-results/
          retention-days: 7

Traces can contain URLs. Because magic tokens live in query strings, treat trace artifacts as sensitive even in a test environment. Restrict repository and artifact access, keep retention short, use synthetic accounts, and make the fixture token useless outside its isolated run. The Playwright trace on retry guide shows how to inspect the recorded sequence without enabling traces for every successful test.

Verify Step 8: run CI=1 npx playwright test tests/magic-link.spec.ts locally. Expect all six tests to pass with the GitHub reporter selected. After pushing, confirm the workflow starts the health endpoint and uploads artifacts only when a test fails.

Troubleshooting

Problem: the browser Clock moves but the link never expires -> The verifier uses server time. Inject or control the backend clock, seed an already expired record, or mock the verification response for a UI-only test. Do not change the operating-system clock on a shared CI runner.

Problem: the outbox endpoint returns 404 -> Wait with expect.poll, verify the recipient string exactly matches, and confirm the UI request returned 202. For an external provider, inspect its sandbox API response and message filters before extending the poll timeout.

Problem: tests pass alone but fail in parallel -> The sample server has one global clock and outbox. Keep workers: 1 for this fixture, namespace every record by worker and run ID, or launch an isolated service per worker. Random email addresses alone do not isolate a shared clock.

Problem: the exact-boundary assertion is inconsistent -> Compare integer epoch milliseconds throughout the service and database. Avoid mixing seconds with milliseconds, rounding to whole minutes, or parsing timestamps without a Z or explicit offset.

Problem: navigation times out after opening the magic URL -> Inspect whether the endpoint returned a redirect, whether the destination became reachable, and whether a service worker intercepted the request. Use the Playwright timeout troubleshooting guide before increasing the timeout.

Problem: the test passes but a token appears in logs or traces -> Redact query strings in server access logs, use synthetic credentials, restrict artifacts, and shorten retention. If your reporter includes full URLs in assertion output, assert the pathname and safe error parameter rather than echoing the original magic URL.

Interview Questions and Answers

Q: Why is page.clock insufficient for most magic link expiration tests?

page.clock controls supported time APIs in the browser context. A magic link verifier normally compares expiration against time inside an API server or database, so that authority does not move with the page. I inject a backend clock or seed a record with a chosen expiry, then use Playwright for navigation and user-visible assertions.

Q: Which expiration boundaries should an SDET test?

I use one instant before expiration, the exact expiration instant, and one instant after it. The exact point proves whether the contract is now >= expiresAt or now > expiresAt. I also cover a normal valid link so a broken setup cannot make every negative test pass for the wrong reason.

Q: How do you retrieve a magic link without weakening production?

I use a sandbox mail provider API or a test adapter available only in an isolated environment. The application still performs its normal send operation, while the test reads the resulting message through protected infrastructure. I never add an endpoint that exposes production tokens or use a personal inbox shared by engineers.

Q: What should happen when a magic link is replayed?

The verifier should reject it after the first successful consumption and issue no authenticated session. The consume operation must be atomic so concurrent requests cannot both succeed. I assert the second response, protected-resource access, session state, and a sanitized audit event.

Q: How do you keep expiration tests fast in CI?

I avoid wall-clock waits and control the server's time dependency. Each test resets state, issues a real token, moves the clock to a boundary, and opens the URL. The resulting run takes seconds while still exercising the real comparison and redirect behavior.

The structured interview section below contains concise versions you can rehearse. For live practice, use the QA interview practice workspace and explain both the time authority and the security assertion.

Best Practices

  • Define expiry semantics in the authentication contract, including the exact boundary, units, timezone representation, and any clock-skew allowance.
  • Use UTC epoch milliseconds or another single canonical representation from issue through verification. Convert only for display.
  • Reset server time, link records, sessions, and the mail outbox before every scenario.
  • Keep one successful control case beside negative cases so infrastructure failure cannot masquerade as secure rejection.
  • Consume a valid link atomically before creating the session. A read followed by a separate unguarded update can allow concurrent replay.
  • Keep token values out of database logs and long-lived artifacts. Store a digest where the design permits it.
  • Use role and label locators for the browser journey. These assertions cover accessible feedback as well as raw routing.
  • Validate the backend decision through a protected resource or session check, not only through an error banner.
  • Test production-like cookie attributes over HTTPS. The local HTTP fixture omits Secure only so it can run on localhost.

Where To Go Next

Adapt the helper boundary to your application rather than copying the fixture endpoints. If your service already accepts an injected clock in unit tests, expose test control through an authenticated internal harness or seed records through a data factory. If email delivery is the main uncertainty, swap /__test/emails/latest for the provider's sandbox API and keep the same MagicEmail return shape.

Next, add rate-limit checks for repeated requests, concurrent double-click tests, audit-log validation, mobile deep-link behavior, and account-enumeration review. The Playwright wait for API response guide helps when the UI send request itself needs inspection. Use Playwright storage state reuse examples only for setup that is unrelated to the one-time link, since pre-authenticated state can hide whether the magic link actually created a session.

For portfolio evidence, capture the boundary matrix, architecture decision, secure logging rules, and a redacted CI report. Then compare the project with target SDET requirements in the QAJobFit resume workspace. A concise explanation of why server time differs from browser time demonstrates more test-design maturity than a screenshot of a passing login.

Conclusion

The reliable way to test email magic link expiration playwright behavior is to control the time authority that performs verification, retrieve the message through an isolated test adapter, and open the same link a customer receives. Assert one millisecond before expiry, the exact boundary, and one millisecond after it so the suite proves the actual comparison.

Add resend, replay, tampering, session, and observability checks to cover the security lifecycle around expiration. With a resettable server clock and synthetic outbox, the complete test remains fast, deterministic, and safe enough for every pull request.

Interview Questions and Answers

How would you test email magic link expiration with Playwright?

I would identify the server as the expiration authority, inject a controllable clock, request the link through the UI, and retrieve it from an isolated mail adapter. I would open it before, at, and after expiresAt, then verify both the user message and authenticated session state. I would also cover resend, replay, malformed tokens, and safe observability.

Why is Playwright Clock not enough for a server-validated magic link?

Playwright Clock changes time APIs inside the browser context. The token verifier usually runs in an API server or database that still sees its own time. I control that backend dependency or seed expiration data instead of assuming browser time affects authorization.

What boundary values reveal an expiration comparison bug?

The decisive values are one unit before expiresAt, expiresAt itself, and one unit after. If the specification says now greater than or equal to expiresAt is expired, the exact case detects an accidental greater-than comparison. I use the smallest unit preserved by the implementation.

How would you prevent flaky email polling in this test?

I would use a provider sandbox API or test outbox with a unique recipient and bounded expect.poll loop. Each scenario would clear prior messages or filter by a run correlation ID. Fixed sleeps are avoided because delivery latency varies.

What security checks belong beside expiration?

I would test cryptographically random tokens, digest storage where appropriate, single use, atomic consumption, tampering, revocation, session creation, rate limiting, and account-enumeration resistance. I would also verify raw tokens are absent from analytics, logs, screenshots, and broadly accessible artifacts.

How should parallel workers handle a controllable clock?

A global clock makes tests interfere, so I would initially run that fixture with one worker. To scale, I would isolate the service and database per worker or namespace clock and link state by a run identifier. Retries are not a substitute for isolation.

Frequently Asked Questions

Can Playwright test an expired email magic link without waiting?

Yes. Control the backend clock or seed a link with a chosen expiry, then navigate to it with Playwright. Advancing page.clock alone is insufficient when the server makes the authorization decision.

What exact magic link expiration boundaries should I test?

Test expiresAt - 1 millisecond, expiresAt exactly, and expiresAt + 1 millisecond. Also keep a normal valid-link case so a setup failure cannot make every rejection look correct.

How should Playwright get the magic link from email?

Read it from a protected sandbox mail API or an isolated test-only outbox. Do not scrape a personal mailbox, expose production mail through a debug route, or log raw tokens.

Does Playwright page.clock change backend token expiration?

No. Playwright Clock controls supported browser-context time APIs, not Node.js services, databases, or remote APIs. The server time source must be controlled separately.

Should a magic link work at its exact expiresAt timestamp?

Most contracts define it as expired when now is greater than or equal to expiresAt, so it should fail at the exact instant. Confirm the intended comparison with the product and security teams, then encode it directly in a boundary test.

What should happen when an expired user requests another magic link?

The replacement should have a new random token and a new expiry calculated from its own issue time. Whether it invalidates other unexpired links is a separate policy that needs its own tests.

How do I test that a magic link is single use?

Open it once and prove authenticated access, then open the identical URL again and assert rejection plus absence of a new session. For stronger coverage, send two verification requests concurrently and confirm only one can consume the record.

Related Guides