Testing
Recommended patterns for writing automated tests against an app that uses AuthKit.
When you add AuthKit to your app, your automated tests need a way to get a signed-in user without a human in the loop. This guide covers the recommended patterns, and the ones to avoid.
The most important rule is this:
The hosted sign-in page is protected by bot detection (Radar) that is specifically designed to flag headless browsers, and the email-based flows (Magic Auth, email verification, MFA) deliver one-time codes out of band. Automating clicks and form fills against the hosted UI makes your tests slow, brittle, and prone to being challenged or rate limited. Instead, authenticate programmatically through the API or an SDK and inject the resulting session into your app.
Before choosing a pattern, separate what you’re actually trying to test. These have different answers:
-
“Does my WorkOS integration work?” – You want to exercise the real authentication flow (sign-in, token refresh, organization selection). Run these against a real Sandbox or Staging environment, using the API directly.
-
“I just need a signed-in user so my app’s other tests can run.” – Authentication isn’t the thing under test; it’s a precondition. Here you want the cheapest possible way to mint a valid session and move on, ideally without touching WorkOS on every test.
Most end-to-end suites are dominated by the second case. Optimize for it.
The supported way to get an authenticated browser session without the hosted UI is to
authenticate via the SDK, ask WorkOS to seal the session, and set the sealed value
directly as the wos-session cookie. This is the same sealed-cookie mechanism the AuthKit
SDKs use in production, so the session your tests run with is a real, valid session.
Create a small, fixed set of email + password users in your test environment once – not one per test run. Per-test creation hits write rate limits and leaves orphaned users behind. Enable Email + Password authentication on the environment so these users can authenticate via the API.
sealedSession is the exact value your app expects in its wos-session cookie. No
redirect, no hosted page, no Turnstile.
In Playwright, do this once in a fixture and reuse the authenticated context across tests:
Cache the sealed cookie per user (a Playwright storage state
file, or cy.session in Cypress) so subsequent tests reuse the session instead of
re-authenticating. This keeps you well under the rate limits below.
A complete, working example of this pattern lives in next-authkit-example.
When the test genuinely needs a user who does not yet exist (sign up, onboarding,
first login), use Magic Auth via the API rather than waiting on an
email. createMagicAuth returns the one-time code directly in the API response, so your
test can complete authentication without polling an inbox:
Keep per-test user creation scoped to the flows that truly need a fresh user. For everything else, reuse the pool. Magic Auth sends are capped at 100 per user per 24 hours, so a single shared user driving many sign up tests can hit that wall – another reason to give parallel workers distinct users (see Running tests in parallel).
Two limits matter most for test suites:
/authenticateis limited to 10 requests per minute, per email. Don’t share a single test user across many parallel workers – give each worker its own pooled user, or cache sessions so you re-authenticate rarely.- User Management writes are limited to 500 requests per 10 seconds, per environment. This is plenty for normal usage, but a CI suite that creates a user per test can spike past it. Pre-provisioning and reuse avoid this entirely.
See the rate limits reference for the full list.
A common surprise when running many tests concurrently is sessions getting “randomly” closed mid-run. There is no concurrent-session cap on a user, so this is not a ceiling on open sessions – the usual cause is refresh-token rotation.
For a public client (no client_secret), every refresh rotates the refresh token and
invalidates the previous one. When several test sessions for the same user refresh at
once, they race, and the loser fails its next call with invalid_grant (“refresh token
already exchanged” / “session has already ended”). A shared cookie store across workers
produces the same symptom.
Two ways to avoid it:
- Use a confidential client. Calling
authenticateWithRefreshTokenwith yourclient_secretdoes not rotate the refresh token, so concurrent refreshes stop racing. The clean pattern for long-lived test agents: mint a session once (Magic Auth works well here, sincecreateMagicAuthreturns the code directly), capture therefreshToken, store it as a CI secret, then have each run authenticate with that refresh token plus theclient_secret. - Give each worker its own user. A distinct email per worker – for example
uitests+{run-id}@yourdomain.comwith a catch-all inbox – gives each its own session and its own Magic Auth budget, sidestepping both the rotation race and the per-user limits above.
The sealed-session pattern earlier in this guide already avoids this for most suites: each worker authenticates once and reuses the cached cookie rather than refreshing repeatedly.
Run tests against a Sandbox or Staging environment, never
Production. Each environment has a fully isolated user pool, so test users can’t collide
with real ones, and you can reset test data freely. Inject that environment’s
WORKOS_API_KEY and WORKOS_CLIENT_ID into your test runner via secrets.
A frequent point of confusion is that no session cookie appears after sign-in when testing against an environment without a custom domain. This is expected, and the trigger is the absence of a custom AuthKit domain – not the environment type. Because custom domains are production-only, this affects local development, every non-production environment, and any production environment that doesn’t yet have a custom domain attached.
Without a custom domain, any cookie WorkOS set would be a third-party cookie relative to your app – practically unusable – so WorkOS does not set one at all.
When using the React SDK (@workos-inc/authkit-react), set devMode={true} on
<AuthKitProvider /> in this situation. This keeps the refresh token
in localStorage instead of a secure, HTTP-only cookie. It is an explicit opt-in you set
in your app code – WorkOS cannot enable it for you, since it lives in the client. If you
deploy without a custom domain and without devMode, the refresh token has nowhere to
persist, the session silently fails to stick, and sign-in appears to loop – the symptom
behind most “no cookie after login” reports.
authkit-nextjs do not use devMode. They seal the session
into the wos-session cookie server-side – the sealed-session pattern used throughout
this guide.
| Scenario | Custom domain | Session cookie | Recommended approach |
|---|---|---|---|
| Local development (React SDK) | No | No | devMode={true} |
| Deployed non-production (React SDK) | No (production-only) | No | devMode={true} |
| Full cookie-based flow | Yes | Yes | Dedicated Production environment |
If you specifically need to exercise the full cookie-based flow end to end, it has to run through a Production environment with a custom domain – for example, a second Production environment dedicated to testing. Be aware that any connections added there (SSO, SCIM, and similar) are billable.
For local development – running your app against a stand-in for WorkOS without network
calls – the WorkOS CLI provides a local emulator via
workos dev. This is well suited to local iteration and agent-driven workflows. For
automated test suites that verify your integration, prefer the sealed-session pattern
above against a real Sandbox environment.