In this article
August 3, 2026
August 3, 2026

Testing WorkOS in CI/CD: A practical guide

A hands on walkthrough for seeding data, testing full login flows, and catching failure paths in your pipeline, all without a single network call to the live WorkOS API.

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

If you've ever tried to test an authentication flow, you've probably run into the same wall: hitting a live API from your test suite is slow and flaky, but hand rolling mocks for JWTs, webhooks, and SSO redirects is its own maintenance job. Every time your auth provider changes a response shape, your mocks drift out of sync, and you end up debugging your test double instead of your app.

@workos/emulate is a local, in memory WorkOS API server built specifically to remove that trade off. It speaks the real WorkOS API surface, so your SDK code doesn't know the difference between talking to the emulator and talking to production. This guide walks through setting it up, seeding realistic data, testing a full login flow end to end, and wiring it into CI so your pipeline never needs network access to WorkOS at all.

Why not just mock the SDK

Mocking the WorkOS SDK directly usually starts small: stub out create_user, stub out authenticate, move on. But auth flows are rarely one call. A real login touches an authorize redirect, a token exchange, a webhook delivery, and sometimes MFA or SSO on top of that. Each of those needs its own mock, and each mock needs to stay accurate as the SDK evolves.

The emulator sidesteps this by being a real HTTP server. Your code makes real requests and gets real, spec shaped responses back, including signed webhooks and valid JWTs. You get the speed and isolation of a mock without maintaining one.

Quickstart

Install the emulator as a dev dependency:

  
npm install --save-dev @workos/emulate
  

Start it from the command line:

  
npx workos-emulate --port 4100
  

By default it listens on http://localhost:4100 and accepts the API key sk_test_default. Point any WorkOS SDK, in any language, at that base URL instead of https://api.workos.com, and use the test API key. That's the entire integration: no code changes beyond configuration.

Check that it's up with a health check, which is handy for CI:

  
curl http://localhost:4100/health
  

Seeding realistic data

Tests are only as good as their fixtures. Rather than creating users and organizations through a setup script before every run, you can seed the emulator from a config file so every test starts from a known, reproducible state.

Create 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

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

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

Then start the emulator with that seed:

  
npx workos-emulate --seed workos-emulate.config.yaml
  

You can seed a lot more than users and orgs, including SSO connections with directory profiles, machine to machine applications with pinned client credentials, and API keys. That's useful for tests that need a fully formed tenant (an org, a couple of users, a role structure, and an active SSO connection) without a mountain of setup code in every test file.

Testing your login flow end to end

The emulator implements the full AuthKit login story, so you can run your app's actual login flow against it: hosted authorize, callback, token exchange, and webhook handling. Nothing about your app's auth code needs to be different for tests.

By default, the authorize endpoint auto redirects with a code, which is convenient for API level tests but not useful for a browser test that needs something to click. Pass --interactive to get a real login page instead:

  
npx workos-emulate --interactive --seed workos-emulate.config.yaml
  

Here's what that looks like in a Playwright test:

  
import { test, expect } from '@playwright/test';

test('user can sign in with WorkOS AuthKit', async ({ page }) => {
  await page.goto('http://localhost:3000/login');
  await page.click('text=Sign in');

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

  await expect(page).toHaveURL(/dashboard/);
});
  

This is also what makes the emulator useful for agent driven browser tests, since there's an actual page to interact with rather than an instant redirect. No dashboard login, no test identity provider, and it works in headless mode.

Verifying webhooks

Every resource creation and authentication outcome in the emulator fires a signed webhook, using the same event names and payload shapes as production. That means you can test webhook handling as part of your regular suite instead of treating it as a manual, staging only step.

Register an endpoint at startup by adding it to your seed file:

  
webhookEndpoints:
  - endpoint_url: http://localhost:5005/webhooks
    events: []
  

An empty events list subscribes to everything. Webhooks are signed the same way production WorkOS signs them, with a WorkOS-Signature header containing a timestamp and an HMAC. If your app already uses the official SDK's webhooks.constructEvent helper to verify signatures, it will verify emulator webhooks without any changes.

A typical test pattern is to spin up a small receiver in your test process, register it as the webhook endpoint, trigger an action (creating a user, for example), and then poll the receiver for the expected event rather than asserting on it immediately, since delivery is asynchronous.

Testing failure paths with error hooks

Happy path tests are easy. The harder, more valuable tests are the ones that check what your app does when WorkOS returns a 422, a 500, or a rate limit. Error hooks let you force specific responses on demand, without touching your application code.

You can add hooks at runtime over HTTP:

  
curl -X POST http://localhost:4100/_emulate/hooks \
  -H "Content-Type: application/json" \
  -d '{"method":"POST","path":"/user_management/users","status":422}'
  

Or manage them programmatically if you're using the emulator's Node API:

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

const emulator = await createEmulator({ port: 0 });

// fail the first 3 attempts, then let requests through
emulator.addErrorHook({
  method: 'POST',
  path: '/user_management/users',
  status: 503,
  count: 3,
});

for (let i = 0; i < 4; i++) {
  const res = await fetch(`${emulator.url}/user_management/users`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${emulator.apiKey}` },
    body: JSON.stringify({ email: 'test@example.com' }),
  });
  console.log(`attempt ${i + 1}: ${res.status}`);
  // attempts 1 through 3 return 503, attempt 4 returns 201
}
  

This is the kind of test that almost never gets written against a live API, since you can't reliably make a production service return a 503 on command. Against the emulator it's a couple of lines, which makes it much more likely your retry logic actually gets exercised before a customer finds the gap.

Wiring it into CI

None of the above is useful in CI unless the emulator is easy to start and stop as part of the pipeline. Since it's a plain HTTP server with no external dependencies, that's straightforward. Here's a GitHub Actions example that starts the emulator in the background, waits for it to be healthy, runs the test suite, and tears it down:

  
name: test

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - name: Install dependencies
        run: npm ci

      - name: Start WorkOS emulator
        run: npx workos-emulate --seed workos-emulate.config.yaml &

      - name: Wait for emulator
        run: |
          for i in $(seq 1 20); do
            curl -sf http://localhost:4100/health && break
            sleep 0.5
          done

      - name: Run tests
        run: npm test
        env:
          WORKOS_API_KEY: sk_test_default
          WORKOS_BASE_URL: http://localhost:4100
  

The same shape works in any CI provider that lets you run a background process and a health check before your test step. Since there's no live API involved, this runs the same way on a laptop, in a pull request, and on a scheduled nightly build, with no shared account, no rate limits, and no risk of test data leaking into a real WorkOS environment.

Testing service to service auth

If part of your system uses WorkOS Connect applications for machine to machine authentication, you can seed those too, with a pinned client ID and secret so a service has known credentials before it ever talks to your dashboard:

  
organizations:
  - name: Acme Corp

connectApplications:
  - name: Backend service
    organization: Acme Corp
    scopes: [posts:read, posts:write]
    client_id: client_local_backend
    client_secret: secret_local_backend
  

A test (or the service itself, in a local dev environment) can then exchange those credentials for a scoped access token exactly as it would in production:

  
curl -s http://localhost:4100/oauth2/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d grant_type=client_credentials \
  -d client_id=client_local_backend \
  -d client_secret=secret_local_backend
  

The token you get back is a real, JWKS verifiable JWT, so if your service validates tokens with a library like jose, it validates emulator tokens with no special casing.

A note on what the emulator is not for

The emulator stores everything in memory, has no real authentication of its own, and isn't meant to be reachable outside your local machine or CI runner. Treat it the way you'd treat any test double: great for development and automated testing, not something to expose on a shared network or use as a substitute for a real WorkOS environment in staging or production.

Next steps

This covers the core workflow: install, seed, run your app's real login flow against a local server, test failure paths on demand, and run all of it in CI without touching the network. From here, the emulator's README is the best reference for the full seed file schema, the complete list of emitted webhook events, and language specific examples beyond Node (Python, PHP, and anything else with a WorkOS SDK follow the same pattern: point the base URL at the emulator and use the test API key).

If you build something with this guide, or hit a gap in what the emulator covers, opening an issue on the repo is the fastest way to get it fixed.