In this article
August 17, 2026
August 17, 2026

Running the WorkOS API locally

WorkOS Emulate runs the WorkOS API on your own machine, so your tests can seed real data, drive full login flows, and force failures without touching prod.

Explore with AI
Open in ChatGPT
Open in Claude
Open in Perplexity

Authentication tends to be the least-tested part of an application, and the reason is structural: the identity provider lives on the other side of the network. Pointing your tests at the live WorkOS API means CI needs network access and real credentials, state piles up between runs, and there's no way to make the API fail on command. The usual result is one happy-path login test, hand-rolled mocks for everything else, and a first real look at your refresh logic on the day production gets slow.

@workos/emulate closes that gap. It's an open-source local WorkOS API server for development and automated testing. Point any WorkOS SDK at it and your integration gets exercised without a single request reaching your live WorkOS environment.

Isometric illustration of data flowing from a server node through curved streams to a bar chart, with an unused dashed arc connecting to a cloud above.

Run it wherever your tests run

The emulator installs however your stack prefers: Homebrew (brew install workos/tap/workos-emulate), a direct binary download, npm (npm install --save-dev @workos/emulate), or Docker from ghcr.io/workos/emulate. The release binaries are self-contained builds for macOS, Linux, and Windows, so a CI runner with no Node, npm, or Bun installed can still start it.

Once it's up, it listens on http://localhost:4100 and accepts the API key sk_test_default. GET /health is the readiness check you poll before your test step runs.

Point your SDK at it

The emulator is a plain HTTP server, so it works with every WorkOS SDK. Override the base URL and use the SDK as you would in production. The README's Python example demonstrates the pattern:

  
import workos

workos.api_key = "sk_test_default"
workos.base_url = "http://localhost:4100"  # ← emulator

# Use the SDK as normal — requests hit the emulator
user = workos.client.user_management.create_user(email="alice@example.com")
  

That's the entire integration. You don't maintain a second mock of the API surface, because the emulator's responses and event shapes come from the same OpenAPI spec WorkOS publishes.

JavaScript suites get a tighter loop. createEmulator starts an emulator in-process, and passing port: 0 gives each test file its own random port so parallel workers never collide:

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

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

const res = await fetch(`${emulator.url}/user_management/users`, {
  headers: { Authorization: `Bearer ${emulator.apiKey}` },
});

emulator.reset();
await emulator.close();
  

Every test starts from the same world

Tests that depend on data an earlier run left behind fail in ways nobody can reproduce. The emulator takes a seed file, workos-emulate.config.yaml in the working directory, or --seed <path>, and rebuilds that exact world on every boot. Users, organizations, memberships, RBAC roles, and SSO connections all seed declaratively:

  
users:
  - email: alice@acme.com
    first_name: Alice
    password: test123
    email_verified: true

organizations:
  - name: Acme Corp
    domains:
      - domain: acme.com
        state: verified
    # Minted into the `entitlements` claim of access tokens scoped to this organization.
    entitlements: [audit-logs, sso]

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

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

Organizations and users also accept an optional id. Pin it to the value your real WorkOS environment emits, and a backend whose database already references a specific org or user lines up with the emulator and stays lined up across restarts.

The same file covers the harder fixtures. Seeded M2M Connect Applications hand a service a known client_id and client_secret before anything talks to a dashboard, and that service exchanges them for a scoped access token at POST /oauth2/token just as it does in production. The token comes back as an RS256 JWT signed with the key the emulator publishes at GET /sso/jwks/:client_id, so a consumer validating through JWKS accepts it with no emulator-specific shims. Seeded API keys are created as real api_key resources and registered in the auth allow-list, so the seeded value actually authenticates requests.

The whole login flow, locally

The emulator implements the end-to-end AuthKit experience, SSO redirect and token exchange included. By default the authorize endpoints redirect straight back to your callback with a code, which is what you want for API-level tests. Pass --interactive and they serve a real HTML login page instead, which is what a browser test needs:

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

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

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


Rather than driving the live Hosted AuthKit page from Playwright, you can use interactive mode to replace the Test Identity Provider: no dashboard login, and it still works headlessly.

Awkward login states show up as themselves. A user with several active memberships gets the same 403 organization_selection_required response with a pending_authentication_token and the organization list that production returns, so the selection step in your callback becomes testable instead of theoretical.

The webhooks fired by the server are also real webhooks. Every resource creation and authentication outcome fires a signed event, with WorkOS-Signature: t=<timestamp>, v1=<hmac> computed as an HMAC-SHA256 over "{timestamp}.{body}", and the official SDKs' webhooks.constructEvent verifies them unchanged. The codes WorkOS would normally email arrive in the payload instead. For example, magic_auth.created carries the Magic Auth code, password_reset.created the reset token, email_verification.created the verification code. A sign-up test can drive the entire flow with no email provider in the loop.

Failure paths production won't hand you

Error hooks make the emulator return whatever you need: validation errors, simulated rate limits, temporary failures. Unhappy paths and retry logic finally get exercised. A hook matches a method and a path — exact, a prefix wildcard like /user_management/*, or * — returns the status you choose with an optional body, and can auto-remove after count uses. Declare them in the seed file, add them over HTTP at /_emulate/hooks at runtime, or call emulator.addErrorHook from a test:

  
errorHooks:
  - method: POST
    path: /user_management/users
    status: 422
    body:
      message: 'Validation failed'
      code: 'unprocessable_entity'
      errors:
        - field: email
          code: invalid
          message: 'must be a valid email'

  - method: GET
    path: /user_management/users
    status: 500

  # Fail the first 3 requests, then let them through
  - method: '*'
    path: /organizations
    status: 503
    count: 3
  


Test auth like the rest of your application

With @workos/emulate, authentication stops being a remote dependency you can only test on the happy path. Your tests run against the real WorkOS API surface with deterministic fixtures, valid tokens and signed webhooks, complete login flows, and failures you can trigger on demand—all without network access, live credentials, or state leaking between runs.

Start with the auth path most likely to break: token refresh, organization selection, webhook handling, or retry behavior. The emulator repo includes the full seed schema, event catalog, per-language examples, and an API coverage matrix.

The result is auth code you can exercise locally, run reliably in CI, and trust before production is the first place it meets an expired token.