playwright
How to Test Clerk Email OTP in Playwright (Without the Test-Mode Trap)
Clerk gives you a beautiful auth UI in about 10 minutes. Then you sit down to write your first Playwright test for sign-up, and things get complicated.
Clerk's Testing tokens work — for a bit. Once you actually want real integration coverage across auth and your app, you start hitting walls:
- Test emails don't actually send. You can't verify the whole flow end-to-end.
- Test tokens don't help with magic links. Magic-link flows need a real URL that shows up somewhere.
+clerk_testaddresses use a fixed OTP. Fine for one test, useless if you want to prove the OTP was generated correctly (say, after touching security code).- Real emails hit rate limits fast. Clerk throttles OTP creation aggressively.
There's a fourth way: catch the real Clerk OTP email with otpmagiclink. Your Playwright test then verifies the actual production flow — no bypass, no shortcut.
Newer to OTP testing in general? Read the ground-up Playwright OTP guide first.
What we're building
By the end of this post you'll have a Playwright test that:
- Opens Clerk's sign-up page
- Submits a real email address
- Waits for Clerk to send the real 6-digit OTP
- Reads the OTP from a sandbox inbox
- Fills it into Clerk's UI
- Asserts you're signed in
No hardcoded codes. No +clerk_test addresses. No burnt Clerk API credits.
Prereqs
- A working Clerk app (this guide uses Next.js +
@clerk/nextjs; the pattern works anywhere) - Playwright installed (
npm i -D @playwright/test) - A free otpmagiclink account for the sandbox key
Step 1 — Install the SDK
npm install --save-dev @otpmagiclink/playwright
Set your key in CI secrets and local .env.test:
# .env.test
OTP_API_KEY=sk_sandbox_your_key_here
Step 2 — Route Clerk's outbound email in test mode
Here's the bit most guides skip: Clerk owns its email infrastructure. You don't send the OTP — Clerk does. So instead of intercepting your sender, we route Clerk's own send into the sandbox.
Two ways:
Option A — Custom email domain (real Clerk pipeline)
Configure a test.yourapp.com subdomain in Clerk's dashboard as the sending domain for your dev/test environment. Point the MX records at the sandbox:
test.yourapp.com MX 10 ingest.otpmagiclink.com.
Every OTP Clerk sends in test mode now lands in your sandbox. Emails to anything@test.yourapp.com are readable via the SDK.
Option B — Clerk webhook + suffix pattern (simpler, slightly lower fidelity)
Don't want custom DNS? Use Clerk's +clerk_test suffix and override the OTP handler in your app to also POST the OTP into the sandbox on generation. You get the flow, just not Clerk's actual email content:
// clerk-webhook.ts — attached to Clerk's user.created + user.updated events
export async function POST(req: Request) {
const evt = await req.json();
if (evt.type === "email.created" && evt.data.to.includes("+clerk_test@")) {
await fetch(`${process.env.OTP_SANDBOX_URL}/v1/verifications`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OTP_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
identifier: evt.data.to,
channel: "EMAIL",
kind: "OTP",
metadata: {
source: "clerk",
otp: evt.data.body_plain.match(/\d{6}/)?.[0],
},
}),
});
}
return Response.json({ ok: true });
}
Rest of the guide uses Option A — it exercises Clerk's real email pipeline instead of routing around it.
Step 3 — Write the test
// tests/clerk-signup.spec.ts
import { test, expect } from "@playwright/test";
import { SandboxClient } from "@otpmagiclink/playwright";
const sandbox = new SandboxClient({
apiKey: process.env.OTP_API_KEY!,
});
test("sign up with Clerk email OTP", async ({ page }) => {
// Unique email so parallel tests never collide
const email = `test-${Date.now()}@test.yourapp.com`;
// 1. Clerk sign-up page
await page.goto("/sign-up");
// 2. Submit the email
await page.fill("input[name=emailAddress]", email);
await page.click("button.cl-formButtonPrimary"); // Clerk's default primary button
// 3. Wait for Clerk's OTP input
await expect(page.getByText(/verification code/i)).toBeVisible();
// 4. Wait for the real OTP to land in the sandbox
const otp = await sandbox.waitForOtp(email, { timeout: 15_000 });
// 5. Fill it in
await page.locator("input[name=code]").fill(otp);
// 6. You're in
await expect(page).toHaveURL("/dashboard");
await expect(page.getByText(/welcome/i)).toBeVisible();
});
That's the whole test. No +clerk_test. No hardcoded 424242. No backend bypass. This is the actual Clerk sign-up — the OTP was generated by Clerk, sent through Clerk's mail infrastructure, captured by the sandbox, and read back into the same test.
Step 4 — Magic links
Clerk's "Sign-in link" flow uses a URL instead of a code. Same pattern, different SDK method:
test("sign in with Clerk magic link", async ({ page }) => {
const email = `test-magic-${Date.now()}@test.yourapp.com`;
await page.goto("/sign-in");
await page.fill("input[name=identifier]", email);
await page.click("text=Continue");
await page.click("text=Email link");
const link = await sandbox.waitForMagicLink(email);
// Simulate the user opening the link from their inbox
await page.goto(link);
await expect(page).toHaveURL("/dashboard");
});
Clerk's magic-link URLs redirect through their infrastructure to your callback, so navigating to the captured URL completes sign-in exactly like a real user would.
Step 5 — Testing expiry
Clerk's OTPs expire in 10 minutes. Verifying that used to mean waiting 10 minutes (nope) or mocking time inside Clerk (fragile). With the sandbox's virtual clock, you fast-forward:
test("OTP is rejected after 10 minutes", async ({ page }) => {
const email = `test-expiry-${Date.now()}@test.yourapp.com`;
await page.goto("/sign-up");
await page.fill("input[name=emailAddress]", email);
await page.click("button.cl-formButtonPrimary");
const otp = await sandbox.waitForOtp(email);
// Time-travel 11 minutes
await sandbox.advanceClock(11 * 60);
await page.locator("input[name=code]").fill(otp);
await expect(page.getByText(/expired/i)).toBeVisible();
});
test.afterEach(async () => {
await sandbox.resetClock();
});
Always reset the clock in afterEach. Otherwise the next test starts 11 minutes in the future and you get to spend a fun afternoon debugging why.
CI
name: Playwright + Clerk
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npx playwright install --with-deps
- run: npx playwright test
env:
OTP_API_KEY: ${{ secrets.OTP_API_KEY }}
CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }}
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }}
Add OTP_API_KEY to repo secrets. That's the whole CI setup — no inbox provisioning, no test-mode juggling.
When something goes wrong
Email arrives but waitForOtp times out. Clerk's email format has changed before. waitForOtp() reads the auto-extracted otp field the sandbox parses. If your custom template uses a non-standard code layout, use getInbox(email) and pull the code yourself.
Rate limits. Clerk throttles OTP creation per email address. Use unique addresses per test (test-${Date.now()}@...) instead of reusing one.
Emails don't reach the sandbox. Verify your test.yourapp.com MX records point at the sandbox ingest domain. dig MX test.yourapp.com will tell you.
Green locally, red in CI. CI runners are slower. Bump waitForOtp timeout to 20-30s if you see intermittent timeouts.
Vs. the alternatives
| Approach | Real Clerk pipeline? | Magic links? | Time-travel? | Setup effort |
|---|---|---|---|---|
+clerk_test fixed OTP | ❌ | ❌ | ❌ | Zero |
| Testing tokens | Partial | ❌ | ❌ | Low |
| Real Gmail + IMAP | ✅ | ✅ | ❌ | High (Google bot detection) |
| MailSlurp + custom regex | ✅ | ✅ | ❌ | Medium |
| otpmagiclink | ✅ | ✅ | ✅ | Low |
Wrap-up
Testing Clerk with the sandbox gets you the fidelity of a real production sign-up flow with the ergonomics of a mock. No trade-off between "fast test" and "realistic test."
Free tier is 1 project with unlimited test runs — enough to prove it against your existing Clerk flows.
Still shopping? See the MailSlurp alternative comparison or the Playwright quick start.
Ship OTP + magic-link tests today
@otpmagiclink/playwright is a one-line SDK for waiting on OTPs and magic links in Playwright, Cypress, or any Node test runner. Free tier included.