AuthStunt

Almost every hosted identity provider ships a backdoor for your tests

I looked at 284 public repositories that use a hosted identity provider and have end-to-end tests. Exactly one has a signup test that reads a real verification code out of a real inbox. One. And it is a vendor's own repository - Stytch's - paying a third-party inbox service to do it.

Thirty-four build a way around the problem instead: a forged session cookie, a mock-jwt-token written straight into localStorage, an environment variable that switches auth off for the test run. Three write a comment saying they tried and could not.

That distribution is not laziness. It is what the documentation asks for.

The backdoor is documented, and it has a name

If you use a hosted identity provider and have ever written an end-to-end test that has to log in, you have probably already met it. It is not a hack anyone discovered. It has a name, a documentation page, and usually a fixed code the vendor tells you to type.

Clerk: "Any email with the +clerk_test subaddress is a test email address." The verification code is 424242, and the docs are explicit about what does not happen: "When testing email verification codes, no email with the verification code will be sent." Turning that on for a production instance is possible, and Clerk's own comment on it is "However, this is highly discouraged."

Stytch publishes sandbox values: OTP 000000, phone +10000000000, and this description of the result: "If your API credentials and the request format are correct you will receive a 200 status response, but no email will actually be sent."

Firebase, for phone auth: "When you provide the fictional phone number and send the verification code, no actual SMS is sent."

Descope describes the mechanism it recommends like this: "Utilizing test users, you can generate OTP codes and Magic/Enchanted link tokens for test users directly using the Descope API and SDK, without sending actual communications to the test account." And on the static OTP variant of that same test-user feature, Descope prints its own warning callout: "This is an insecure method and is only recommended when generated OTP codes are not viable for testing."

That warning is worth placing precisely, because I got it wrong in my own notes first. It sits on static OTP codes, not on the generate-OTP mechanism Descope recommends. "Vendor labels its own backdoor insecure" is the better story and the false one.

Auth0 is the most explicit of the ten. Its own writeup on Cypress testing states the principle - "The best practice is never to visit or test third-party sites over which you have no control." - and then argues its way around it: "Keep in mind that you must not use this grant on your public clients. This is an exception to this rule because it is an end-to-end test that won't be used by real users."

Out of ten providers, eight give you an official way to skip the real message; seven name the feature outright. Supabase gets there differently: in local development, auth.email.enable_confirmations defaults to false and an inbucket mail catcher is already in the stack, so the backdoor is the default rather than a feature you switch on. Two have nothing. SuperTokens' testing documentation covers API testing with Postman, debug logs and troubleshooting, with no test mode and no fixed OTP. And Kinde says the quiet part out loud - "Kinde requires OTP email verification when signing up for a new user." - then points at somebody else's product: "Test email services like Mailosaur or Mailtrap provide API access to test inboxes, making it easy to retrieve OTP codes programmatically."

What the backdoor buys, and what it takes

The trade is worth stating fairly, because for most teams it is a good trade. A documented backdoor exercises your code: route guards, session handling, post-login redirects, role checks, all running against the real provider, deterministically, at CI speed. That is most of what most people want from an auth test.

What it does not touch is the message. Whether the template renders. Whether it arrives. Whether the link is clickable, points at the right host, and still works when a human takes eleven seconds to read the email first. The backdoor's whole point is that the message never gets made.

The vendors do not hide this seam. Stytch marks where it is: "The sandbox values below are only available when calling the Stytch API directly. They will not work when used with a frontend or mobile Stytch SDK."

Clerk's own Playwright example repo says it in two comment lines that belong together:

// Unique email per run so concurrent runs don't collide.
// Uses +clerk_test so 424242 works as the verification code.

clerk/clerk-playwright-nextjs, e2e/app.spec.ts:35-36.

A repository in my corpus, woody34/rescope, says it plainer still, on the line after it reads a code out of an emulator's escape-hatch API: "In real apps this would come from the email inbox."

The part I measured

A word on sample selection, since it decides everything downstream. The ten providers are the nine whose SDKs the corpus search actually turned up, plus Dynamic, which is in the documentation survey only. The 284 repositories are what survives a rule set fixed before the data was seen: a package.json holding both an identity SDK and an E2E runner, then not a fork, not archived, pushed within eighteen months, an E2E config actually present. That ran against a frame which censused every qualifying repository for the five smaller providers and took a seeded random 250 per provider for the four largest. No query anywhere in the pipeline sorted by stars or by "best match", and vendor-owned repositories are counted in their own tier rather than mixed into the denominator.

Then the method, including where it broke. I re-downloaded all 284 repositories over a different transport than the original pass used - the tarball API instead of git clone - recording each HEAD SHA, then applied detection signals written from scratch. All 284 still existed. Then I audited my own download, because I had piped curl into tar and swallowed stderr, which means a truncated download fails silently and every count after it is quietly short. Two repositories had in fact been truncated, one at 36 of 272 files and one at 14 of 865. I refetched both. Neither had a hit on either signal, so no number moved. It could easily have gone the other way.

The one repository that consumes a real code is stytchauth/stytch-browser, Stytch's own, and it does it with Mailosaur, a paid service:

const email = `${emailName}+${timestamp.getTime()}@${MAILOSAUR_SERVER_ID}.mailosaur.net`;

then cy.mailosaurGetMessage(, then const tokenLink = email.text.links[0].href;, then cy.visit(tokenLink);. The dependency is pinned as "cypress-mailosaur": "5.0.0", and the key comes from CI as cypress_mailosaur_api_key: ${{ secrets.CYPRESS_MAILOSAUR_API_KEY }}. The entire E2E suite is three spec files.

That "1" survived two unrelated ways of asking. The first pass scanned test bodies for vendor-specific SDK verbs. The second asked something else: can this repository talk to an inbox at all? It scanned dependency manifests, docker-compose files, CI workflows and test code for test-inbox SDKs, IMAP clients, local mail catchers, and Gmail/Graph APIs. 43 repositories had some inbox capability somewhere; 6 had it inside a test file near identity code; opening all six by hand left one, the same one. The other five: a Mailinator mention inside a skipped test's comment; ethereal.email in a non-identity flow; IMAP as a product feature; a getMailhogEmails helper never called; and test@testmail.com, a hardcoded login my regex mistook for the testmail.app service.

Thirty-four repositories out of 284 build their own backdoor. They fall into two shapes. Forged credentials, injected straight into the browser:

  • Luigi-Faldetta/fit-log: win.localStorage.setItem('clerk-db-jwt', 'mock-jwt-token');
  • SDG-AI-Lab/Digital_Technologies_Radar, which annotates itself: // Set logged in state above window.localStorage.setItem('drr-current-user-id', 'admin');
  • tensr-xyz/tensr-platform-web: a cookie name: 'stytch_session_token', / value: 'e2e-playwright-session', plus the same key in localStorage - and the product code reads that exact key: const SESSION_TOKEN_KEY = 'stytch_session_token';

And switches, mostly set in the harness's own webServer.command - command: 'cross-env VITE_E2E_SKIP_AUTH=true vite --port 5174 --strictPort', in vandean25/auto-core-platform, VITE_E2E_BYPASS_AUTH in gumacahin/mis-capstone, NEXT_PUBLIC_BYPASS_AUTH in a committed .env. Those prefixes are part of the names: a bare BYPASS_AUTH or SKIP_AUTH appears in none of the 284, only in my own earlier notes, where I had shortened them. The rest are in the published manifest.

Now the parts of that number that argue against me. The original pass published 31/284 (10.9%) and hand-checked it. My independent pass found 34/284 (12.0%) - a relative gap of 9.7%, which clears my own 10% agreement threshold by three tenths of a point. That is agreement at the edge, not a clean match, and I will not write it as one. The gap runs in the direction that flatters the older number. Its keyword list required the injected storage key to contain one of a fixed set of words, so it missed SDG-AI-Lab/Digital_Technologies_Radar (cypress/e2e/create-disaster.cy.ts:2-4) and ubcdiscovery/ubc-discovery, which sets ubc-discovery-test-google-user at web/e2e/identity-convergence.spec.ts:104-110 and reads that key back in product code at web/app/lib/firebase.ts:22. Both keys contain user, which was not on the list. It also missed kil-dev/kil.dev, which has a whole module for the job at src/lib/admin-test-bypass.ts:1-2, only because the spec imports the cookie constant instead of writing the string. My set is a strict superset: zero repositories in the published 31 failed to reappear. My scan also produced a thirty-fifth hit, MCPJam/inspector, which is my own false positive: sessionStorage.setItem("oauth-debugger-e2e-started", "true") is a "have I run yet" flag, matched only because "oauth" contains "auth". I removed it.

The honest limit: I opened only the four-repository difference by hand, not all 34. The 31 shared repositories rest on the earlier pass's hand check, which self-reported 17 of 20 correct on a 20-repository sample. If you need a number firmer than that, someone has to open all 34 by hand, and nobody has.

Three repositories say in a comment that they gave up. The most complete is drifter089/orgOS, tests/auth-unauthenticated.spec.ts:117-127:

  test.skip("should complete sign-in flow with valid credentials", async ({
    page,
  }) => {
    // NOTE: This test is skipped because WorkOS requires email verification (OTP)
    // which cannot be automated in this test environment without access to the email inbox.
    //
    // To enable this test, you would need:
    // 1. Configure WorkOS to disable email verification for test environment
    // 2. Use WorkOS test mode API if available
    // 3. Integrate with an email testing service (e.g., Mailinator, Mailtrap)
    // 4. Use WorkOS impersonation feature if available in your plan

All four items are there on purpose. Cutting the list after item 2 makes it read as a dead end, and it is not one: the developer who gave up knew commercial inboxes exist and named two of them. The second is intelogroup/ugent - "NOTE: Full OTP login requires a real email. Tests that need auth are marked with [auth-required] and skip if UGENT_TEST_OTP is not set." The third, amirrudd/flyerboard, does not belong in an email argument and I will not pretend otherwise: its blocker is Descope SMS OTP.

Three out of 284, and that three is itself a correction: an earlier summary table of mine said five. The machine output of that same pass listed three, and rescanning all 284 confirmed three.

Where the backdoor stops: running in parallel

The backdoor gets you logged in. It does not get you logged in twice at once.

Clerk addresses this directly in its Playwright guide, and the answer is to give up the parallelism: "Setup must be run serially, this is necessary if Playwright is configured to run fully parallel", with the sample calling setup.describe.configure({ mode: 'serial' }).

Playwright's own documentation recommends a pool of accounts for exactly this case: "This is the recommended approach for tests that modify server-side state. ... We will need multiple testing accounts, one per each parallel worker." (Elided: three sentences explaining that each worker authenticates once and reuses that state.) The sample code then calls const account = await acquireAccount(id); - twice, in two separate examples - and never defines acquireAccount anywhere on the page. The doc gives the call site, not the implementation, which is a reasonable place to stop; Playwright cannot know your provider. The only guidance is in the sample's comments: acquire a unique account, or keep a list of precreated ones, and make sure they are unique so teammates running tests at the same time do not collide. The pool is your problem.

yravan/cashlens did the assignment. It has per-account storage state and a two-account pool in its global setup - apps/web/e2e/global.setup.ts:16-17, keyed a and b, each with its own email and its own storageState file. It follows the official pattern. And its Playwright config still reads:

  workers: 1, // parallel clerk.signIn is flaky (clerk/javascript#7891)

That issue body describes it precisely: "When using @clerk/testing with Playwright and --workers=2 (or more), all tests fail with TimeoutError: page.waitForFunction: Timeout 15000ms exceeded at clerk.signIn(). With --workers=1, authentication works 100% reliably."

The issue is closed - 2026-04-03, state_reason: completed. It has exactly two comments, both from the same user, whose author_association is NONE. No comment on it from the vendor.

The fix people take away from that thread is usually stated as "give each worker its own test account". The actual text is more modest, and getting it right matters: it is the second suggestion in that comment, introduced as "One practical workaround while waiting for a proper fix: pre-authenticate each worker with a dedicated test user and save the storage state to separate JSON files, then load each worker's state from its own file rather than calling signIn() concurrently." The primary suggestion in the same comment was a separate browser profile directory per worker plus a file lock. One community member, two ideas, one of them explicitly labelled a stopgap. That is the documented state of the art for this wall.

Most of you do not need what I am building

I started on this because of my own product, giaoanai. It is mine, which means it represents nobody but me and proves nothing about anybody else's stack, and that is why I am publishing zero numbers from it here. It sends real email in real signup flows, and I could not test that end to end without a real inbox. Everything above is what I found when I went looking for how other people had solved it.

If you control your own sending stack, you do not need a product for this. Run mailpit next to your dev server, point SMTP at it, read the message back over its HTTP API. Single binary, web UI, JSON API. The glue is a few dozen lines and you own all of it. Supabase already bundles an equivalent for local development.

If your mail goes out through a third party and you want support behind the inbox, buy one. Mailosaur is what Stytch recommends in its own docs - "we generally recommend using a platform like Mailosaur to set up a programmatically accessible email or SMS inbox" - and what Stytch's own suite uses, as quoted above. Kinde points at Mailosaur or Mailtrap. These products work, they predate me, and if one fits, use it.

What is left over is narrow: several workers at once, each needing its own identity and its own real message, against a provider whose test mode is per message rather than per worker. That is the case AuthStunt is for. It hands each worker its own address and its own real message, and that is all I am going to claim in a post whose whole argument is that claims need receipts.

Check my work

Every quotation above carries a repository, a path, a line number and the SHA it was read at; vendor quotes carry the URL, read 2026-08-16. All of it ships with the corpus, the detection signals and the case-by-case verdicts, so you can disagree with a specific judgment rather than with a summary. Public repositories change: if a line number no longer matches, the SHA still pins what I read.

Dataset and per-quote provenance: github.com/ivermin1123/authstunt/tree/main/research/post-1-repro-kit

If I filed a repository wrong, tell me which one and why. The number that would embarrass me most is the "1 out of 284", and the fastest way to move it is somebody pointing at repository number two.

What AuthStunt is

AuthStunt is a self-hosted test identity service. It leases each parallel test worker its own address, and binds every claim to exactly one real message.

Before you reach for it, check whether you need it. Most people do not.

  • You control your own sending stack and only need to catch the mail. Run mailpit next to your dev server, point SMTP at it, and read the message back over its HTTP API. Single binary, web UI, JSON API. The glue is a few dozen lines and you own all of it. You do not need AuthStunt for this.
  • Your mail goes out through a third party and you want support behind the inbox. Buy Mailosaur or Mailtrap. These products work, they predate AuthStunt, and if one fits, use it.
  • Several workers at once, each needing its own identity and its own real message, against a provider whose test mode is per message rather than per worker. That is the narrow case left over, and the one AuthStunt is for.

Source and documentation: github.com/ivermin1123/authstunt. Reproduction kit for the post above: research/post-1-repro-kit.