<!-- llms.txt: https://workos.com/llms.txt -->

# Testing

## 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](https://github.com/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](#what-to-test), and how to keep a smaller suite against a real WorkOS environment.

> **Note:** 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.

***

## Choose a testing layer

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.

***

## Local testing with the emulator

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](https://github.com/workos/emulate/blob/main/SUPPORTED.md) before relying on a specific endpoint or behavior, and keep a real-environment test for the integration paths that matter most.

### Installation

```
# Homebrew (macOS and Linux)
brew install workos/tap/workos-emulate

# npm, for JavaScript projects
npm install --save-dev @workos/emulate

# or run without installing
npx @workos/emulate
```

Self-contained binaries for macOS, Linux, and Windows are also attached to each [GitHub release](https://github.com/workos/emulate/releases).

### Start the emulator from JavaScript tests

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:

```ts
import { createEmulator } from '@workos/emulate';

const emulator = await createEmulator({
  port: 0,
  seed: {
    users: [{ email: 'test@example.com', password: 'secret' }],
  },
});

// emulator.url and emulator.apiKey configure the code under test

emulator.reset(); // clear resource state between tests
await emulator.close();
```

After calling `reset`, route-level authentication events are no longer emitted. Start a fresh emulator instance instead when a test asserts on authentication events.

### Run a standalone server

For other languages, Docker-based test environments, or local development, run the emulator as a standalone server:

```bash
workos-emulate --seed workos-emulate.config.yaml
```

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:

```bash
workos-emulate --seed workos-emulate.config.yaml &
curl --retry 10 --retry-connrefused -fsS http://localhost:4100/health
```

| 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                              |

### Point your app at the emulator

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.

```ts title="Node.js"
import { WorkOS } from '@workos-inc/node';

const workos = new WorkOS('sk_test_default', {
  apiHostname: 'localhost',
  port: 4100,
  https: false,
});
```

The same pattern works for any language with a WorkOS SDK, as all of them expose a base URL override.

### Seed deterministic test data

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:

```yaml title="workos-emulate.config.yaml"
users:
  - email: alice@acme.com
    first_name: Alice
    password: test123
    email_verified: true

organizations:
  - name: Acme Corp
    domains:
      - domain: acme.com
        state: verified
    memberships:
      - email: alice@acme.com
        role: admin
        status: active

roles:
  - slug: admin
    name: Admin
    permissions: [posts:read, posts:write]

permissions:
  - slug: posts:read
    name: Read Posts
  - slug: posts:write
    name: Write Posts
```

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.

### Simulate failures with error hooks

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:

```bash
# Make user creation fail with a 422
curl -X POST http://localhost:4100/_emulate/hooks \
  -H "Content-Type: application/json" \
  -d '{"method":"POST","path":"/user_management/users","status":422}'
```

```ts
// Fail the first 3 requests, then let them through — exercises retry logic
emulator.addErrorHook({
  method: 'POST',
  path: '/user_management/users',
  status: 503,
  count: 3,
});
```

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.

### Assert on webhooks

[Register a webhook endpoint](https://workos.com/docs/events/data-syncing/webhooks) and every resource creation and authentication outcome fires a signed webhook, exactly like production.

```yaml title="workos-emulate.config.yaml"
webhookEndpoints:
  - endpoint_url: http://localhost:5005/webhooks
    events: [] # an empty list subscribes to everything
```

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`).

### Browser-based end-to-end tests

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:

```ts
test('sign-in flow', async ({ page }) => {
  await page.goto('http://localhost:3000/login');
  await page.click('text=Sign in');

  // The emulator serves the login page
  await page.fill('input[name="email"]', 'alice@acme.com');
  await page.click('button[type="submit"]');

  // Redirected back to your app with a valid session
  await expect(page).toHaveURL(/dashboard/);
});
```

This works in headless browsers and requires no dashboard configuration or real identity provider.

***

## What to test

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 callback route

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 `error` parameter instead of a `code` (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.

### Session lifecycle

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.

### Protected routes

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.

### Authorization

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_required` response. Verify your app completes the selection flow instead of failing. The emulator reproduces this exactly as production does.
- If you use [JWT templates](https://workos.com/docs/authkit/jwt-templates), seed the same template into the emulator and assert your code reads the custom claims correctly.

### Webhook handlers

Webhook endpoints are publicly reachable by your server, so their tests are security tests:

- A request with a missing, malformed, or wrongly-signed `WorkOS-Signature` header 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.updated` before the `user.created` your handler expects) don't crash or corrupt state.

### Failure handling

The WorkOS API may occasionally be slow, due to circumstances beyond our control. Use error hooks to force various error cases:

- A `5xx` or timeout during token refresh must not destroy the session — treat it as transient and retry, reserving sign-out for a terminal `invalid_grant`. See [session resilience](https://workos.com/docs/authkit/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.

***

## Testing against a real environment

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](https://workos.com/docs/authkit/environments) 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 seed` in the [WorkOS CLI](https://workos.com/docs/authkit/cli-installer), which can also tear everything down cleanly afterwards.
- Keep credentials in your CI secret store; staging API keys are still secrets!

### Create an authenticated browser session

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:

```javascript
import { randomUUID } from 'node:crypto';
import { WorkOS } from '@workos-inc/node';

const workos = new WorkOS(process.env.WORKOS_API_KEY);

async function createWorkerSession(runId, workerIndex) {
  const email = `authkit-ci+${runId}-${workerIndex}@example.com`;
  const password = randomUUID();
  const user = await workos.userManagement.createUser({
    email,
    password,
    emailVerified: true,
  });

  const { sealedSession } = await workos.userManagement.authenticateWithPassword({
    clientId: process.env.WORKOS_CLIENT_ID,
    email,
    password,
    session: {
      sealSession: true,
      cookiePassword: process.env.WORKOS_COOKIE_PASSWORD,
    },
  });

  return { sealedSession, userId: user.id };
}
```

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`:

```javascript
const { sealedSession, userId } = await createWorkerSession(runId, workerIndex);

const context = await browser.newContext();
await context.addCookies([
  {
    name: 'wos-session',
    value: sealedSession,
    url: 'http://localhost:3000',
    httpOnly: true,
    secure: false,
    sameSite: 'Lax',
  },
]);
```

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.

### Run tests in parallel

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](https://workos.com/docs/reference/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.

### Avoid automating Hosted AuthKit in real environments

Do not drive the live Hosted AuthKit sign-in page from an automated suite. [Radar](https://workos.com/docs/authkit/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.
