Testing
Run the WorkOS API locally with the emulator and learn what to cover when testing your AuthKit integration — sign-in flows, sessions, webhooks, and failure handling.
Introduction
Your authentication code deserves the same test coverage as the rest of your app. Pointing tests at the live WorkOS API is a poor fit for that, because it requires network access and real credentials in CI, accumulates state between runs, and can’t be forced to fail on demand.
WorkOS Emulate solves this. It’s an open source, in-memory emulator of the WorkOS API that runs on your machine, implements the core AuthKit login story – authorization, code exchange, sessions, organization selection, signed webhooks – and lets you inject failures to exercise your error handling. Event names and payload shapes are generated from the WorkOS API specification, so what your tests see matches production.
This guide covers running the emulator, what to test, and how to keep a smaller suite against a real WorkOS environment.
The emulator is a development and testing tool. It keeps all data in memory, performs no real authentication, and should never be exposed to production traffic or seeded with production secrets.
Use the emulator and the real API for different jobs:
| Layer | Use it for | Approach |
|---|---|---|
| Local emulator | Most integration, failure-handling, and browser end-to-end tests | Seed deterministic data and run an isolated instance per worker |
| Real staging environment | A smaller suite that verifies your app against the WorkOS service | Authenticate programmatically and reuse a session for each worker |
The emulator should carry most of the suite because it is fast, isolated, and has no rate limits. Keep enough real-environment coverage to verify the boundary between your app and WorkOS.
The emulator is a plain HTTP server, so it works with every WorkOS SDK. In a test environment, you can point the SDK’s base URL at the emulator instead of issuing requests to https://api.workos.com.
The emulator is a stand-in for the platform, not a complete copy. Check the supported features matrix before relying on a specific endpoint or behavior, and keep a real-environment test for the integration paths that matter most.
Self-contained binaries for macOS, Linux, and Windows are also attached to each GitHub release.
JavaScript test suites should usually start an isolated emulator from their test setup. The createEmulator function starts the emulator in-process on a random port, so parallel workers never collide:
After calling reset, route-level authentication events are no longer emitted. Start a fresh emulator instance instead when a test asserts on authentication events.
For other languages, Docker-based test environments, or local development, run the emulator as a standalone server:
The emulator listens on http://localhost:4100 and accepts the API key sk_test_default by default. Use GET /health as a readiness check when starting it in the background:
| Flag | Description |
|---|---|
--port <port> |
Port to listen on (default 4100) |
--seed <path> |
Seed file with users, organizations, roles, webhook endpoints, and more |
--interactive |
Serve real login pages for browser-based end-to-end tests |
--signing-key <path> |
Pin the RSA signing key so tokens and JWKS stay stable across restarts |
--issuer <url> |
Pin the iss claim on minted tokens |
--json |
Machine-readable output for scripts and CI |
Override the SDK’s base URL in your test configuration and use the SDK as normal, so that requests hit the emulator instead of the real API.
The same pattern works for any language with a WorkOS SDK, as all of them expose a base URL override.
Tests should not depend on state left behind by earlier runs. Declare the users, organizations, roles, and permissions your tests need in a seed file, and the emulator recreates that exact world on every boot:
Both organizations and users accept an optional id. Pin ids to match what your real WorkOS environment emits, so a backend whose database already references a real organization or user id lines up with the emulator, and stays stable across restarts.
Error hooks force the emulator to return non-200 responses so you can test how your app handles any rare WorkOS API failures. Register them in the seed file, over HTTP at runtime, or programmatically:
Hooks match a method and path (exact, prefix wildcard like /user_management/*, or *), return a status of your choosing with an optional custom body, and can auto-remove after count uses.
Register a webhook endpoint and every resource creation and authentication outcome fires a signed webhook, exactly like production.
Codes that WorkOS would deliver by email arrive in the webhook payload instead: magic_auth.created carries the Magic Auth code, password_reset.created the reset token, and email_verification.created the verification code. Your test can drive an entire login flow from webhooks alone, with no email provider in the loop.
Delivery is fire-and-forget with no retries, so poll your receiver in tests rather than asserting immediately. All events can also be queried at GET /events (and filter with a query parameter, like ?events[]=user.created).
By default the authorize endpoints immediately redirect back to your callback with a code – ideal for API-level tests. For browser tests, pass --interactive and the emulator serves a real login page instead:
This works in headless browsers and requires no dashboard configuration or real identity provider.
WorkOS tests AuthKit itself – the hosted UI, the token issuance, the protocol plumbing. Your job is the integration seam: the routes, session handling, and authorization logic you wrote. Focus your coverage there.
| Area | What to verify |
|---|---|
| Callback route | Code exchange creates a session; error redirects are handled |
| Session lifecycle | Expired tokens refresh; rotated refresh tokens are stored; logout revokes |
| Protected routes | Unauthenticated requests are rejected; authenticated ones pass |
| Authorization | Role and permission claims gate access; multi-organization users work |
| Webhook handlers | Invalid signatures are rejected; duplicate deliveries are idempotent |
| Failure handling | API errors and timeouts degrade gracefully instead of signing users out |
The OAuth callback is the front door of your integration. Verify that a valid code is exchanged for a session and the user lands where you expect:
- WorkOS can redirect back with an
errorparameter instead of acode(for example, when a user cancels). Your callback should show something sensible, not a stack trace. - If you pass
state, assert that a missing or tampered value is rejected.
Sessions fail in ways that only show up over time, so simulate time passing instead of waiting for it:
- An expired access token triggers a refresh and the request succeeds transparently.
- Your app stores the new refresh token after every refresh. Refresh tokens may rotate in production; the emulator always rotates them and invalidates the old one, so a client that keeps using a stale token fails locally instead of in production.
- Logout clears your session state and revokes the WorkOS session, not just one of the two.
Test your middleware or route guards from both sides: an unauthenticated request to a protected route is redirected or rejected, an authenticated request passes, and public routes stay public. These tests are cheap and catch the most embarrassing class of bug – a route that silently lost its protection.
If your app reads role, permissions, or custom claims from the access token, test the decisions your code makes with them:
- A user with the right permission gets through; one without it is denied.
- Users who belong to multiple organizations receive the
organization_selection_requiredresponse. Verify your app completes the selection flow instead of failing. The emulator reproduces this exactly as production does. - If you use JWT templates, seed the same template into the emulator and assert your code reads the custom claims correctly.
Webhook endpoints are publicly reachable by your server, so their tests are security tests:
- A request with a missing, malformed, or wrongly-signed
WorkOS-Signatureheader is rejected. - The same event delivered twice does not double-apply – deliveries are at-least-once, so handlers must be idempotent.
- Events arriving out of order (an
organization_membership.updatedbefore theuser.createdyour handler expects) don’t crash or corrupt state.
The WorkOS API may occasionally be slow, due to circumstances beyond our control. Use error hooks to force various error cases:
- A
5xxor timeout during token refresh must not destroy the session – treat it as transient and retry, reserving sign-out for a terminalinvalid_grant. See session resilience for the full pattern. - Rate limits (
429) and outages (503) are retried with backoff where you expect them to be. - Validation errors (
422) surface actionable feedback to the user rather than a generic failure.
The emulator covers most unit, integration, and local end-to-end tests. You can run the remaining tests against a dedicated WorkOS environment that never serves production traffic. A staging environment is the right choice for most of these tests:
- Create a separate WorkOS environment for staging or CI so test data can’t leak into production and API keys stay scoped.
- Seed it from a declarative YAML file with
workos seedin the WorkOS CLI, which can also tear everything down cleanly afterwards. - Keep credentials in your CI secret store; staging API keys are still secrets!
When authentication is only a precondition for a browser test, authenticate through the server-side SDK, ask WorkOS to seal the session, and set that value as your app’s session cookie. This uses the same sealed-session format as the server-side AuthKit SDKs without driving Hosted AuthKit.
Create a unique email and password user for each parallel worker during test setup, then authenticate once per worker rather than once per test. This avoids maintaining a stable pool of shared users and prevents concurrent runs from mutating the same user:
Use an email domain you control, and pass the CI run identifier and worker index from your test runner. Marking the email as verified is appropriate here because this is a dedicated test environment and email verification is not the behavior under test.
Add the sealed value to the browser context using the cookie name and URL configured by your app. For example, the default cookie name for the server-side AuthKit SDKs is wos-session:
Cache the authenticated state for the worker so tests reuse it. Treat that state like a credential: keep it out of source control and discard it after the run. Delete the user during worker teardown with workos.userManagement.deleteUser(userId).
For tests that specifically exercise Magic Auth sign-up, createMagicAuth returns the one-time code in its response. Pass that code to authenticateWithMagicAuth and seal the resulting session. Do not use this flow for generic browser setup, because it sends an email and is subject to the per-email limits below.
Public-client refresh tokens rotate. When workers share one session, concurrent refreshes can invalidate the token another worker is about to use and cause invalid_grant errors. Give each worker its own user and authenticated state instead of sharing a cookie or storage-state file across the suite.
Real environments also enforce AuthKit rate limits, so be sure to plan your tests accordingly. Create one user per worker, authenticate once during worker setup, and reuse sessions to stay below these limits.
Do not drive the live Hosted AuthKit sign-in page from an automated suite. Radar is designed to challenge bot-like traffic, and email-based flows deliver one-time codes out of band. These tests become slow, brittle, and prone to challenges or rate limits.
Use the emulator’s --interactive mode when a browser test needs to exercise a login page. Against a real environment, authenticate through the API or an SDK and inject the resulting session into your app.