Local WorkOS API emulator for tests and development.
workos-emulate
workos-emulate --port 9100 --json
workos-emulate --seed workos-emulate.config.yaml
workos-emulate --interactive # serve login pages for E2E browser testingThe emulator defaults to http://localhost:4100 and the API key sk_test_default.
Use GET /health for readiness checks.
The emulator is a plain HTTP server, so any language can use it — just point your WorkOS SDK's base URL at the emulator instead of https://api.workos.com.
Start the emulator in the background (or in a separate terminal):
workos-emulate --port 4100 --seed workos-emulate.config.yamlimport 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")
# Add an error hook at runtime to test failure handling
import requests
requests.post("http://localhost:4100/_emulate/hooks", json={
"method": "POST",
"path": "/user_management/users",
"status": 422,
"body": {"message": "Validation failed", "code": "unprocessable_entity"},
})
# Now this call returns a 422 — test your error handling
try:
workos.client.user_management.create_user(email="bob@example.com")
except Exception as e:
print(f"Handled error: {e}")use WorkOS\WorkOS;
$workos = new WorkOS('sk_test_default');
$workos->setApiBaseUrl('http://localhost:4100'); // ← emulator
// Use the SDK as normal
$user = $workos->userManagement->createUser(['email' => 'alice@example.com']);
// Add an error hook at runtime
$ch = curl_init('http://localhost:4100/_emulate/hooks');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode([
'method' => 'POST',
'path' => '/user_management/users',
'status' => 500,
]),
CURLOPT_RETURNTRANSFER => true,
]);
curl_exec($ch);
curl_close($ch);
// Now user creation returns a 500 — test your error handling
try {
$workos->userManagement->createUser(['email' => 'bob@example.com']);
} catch (\Exception $e) {
echo "Handled error: " . $e->getMessage();
}The same pattern works for any language with a WorkOS SDK (Ruby, Go, Java, etc.) — override the base URL and use the /_emulate/hooks HTTP API to manage error hooks from your test setup.
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();The reset() method clears all data and re-seeds from the original config, but route-level authentication events will not work after reset. This is because Hono's router cannot be modified after it's built, so the EventBus cannot be re-registered with the collection hooks.
This limitation is acceptable for test scenarios where reset() is primarily used to clean up state between tests, but it means:
- After calling
reset(), authentication events (authentication.*_succeeded,authentication.*_failed) will not be emitted - Resource lifecycle events (user.created, organization.created, etc.) will still work
- If you need authentication events after reset, you must create a new emulator instance
const emulator = await createEmulator({ port: 0 });
// First run: authentication events work
await fetch(`${emulator.url}/user_management/authenticate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ grant_type: 'password', email: 'test@example.com', password: 'secret' }),
});
// authentication.password_succeeded webhook is delivered
emulator.reset();
// Second run: authentication events DO NOT work
await fetch(`${emulator.url}/user_management/authenticate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ grant_type: 'password', email: 'test@example.com', password: 'secret' }),
});
// NO authentication.password_succeeded webhook is delivered
// Solution: create a new emulator instance if you need authentication events
await emulator.close();
const newEmulator = await createEmulator({ port: 0 });Create workos-emulate.config.yaml in the current directory or pass --seed <path>.
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 PostsBoth organizations and users accept an optional id. Pin it to match what your real
WorkOS environment emits, so a backend whose database already references a real org or user id
lines up with the emulator — and stays stable across restarts, which otherwise mint a fresh id
each time the seed re-runs. Omit it and an id is generated as before.
organizations:
- id: org_01ABC... # optional; generated if omitted
name: Acme Corp
users:
- id: user_01XYZ... # optional; generated if omitted
email: alice@acme.com
password: test123A pinned id must be a non-empty string, and ids must be unique within organizations and
within users (a duplicate would silently overwrite the earlier record in the store). The
pinned id is what the API, login tokens, and webhooks report for that resource.
For OAuth-based logins (authorization_code, refresh_token, device_code), the authenticate
response omits authentication_method by default: the hosted authorize flow carries no provider
information, and the spec's authentication_method enum has no generic OAuth value — only
provider-specific ones like GoogleOAuth. Set oauth_provider on a seeded user to have the
response report a concrete, spec-valid provider. (Password, Magic Auth, and SSO logins already
report their own method and need no configuration.)
users:
- email: alice@acme.com
oauth_provider: GoogleOAuth # reported as authentication_method for this user's OAuth loginsSeed M2M Connect Applications so a service has a known client_id / client secret pair on
startup — ideal for docker compose up style local development where credentials must exist
before any dashboard interaction.
organizations:
- name: Acme Corp
connectApplications:
- name: Backend Service
type: m2m # default; use `oauth` for an OAuth app
organization: Acme Corp # required for m2m; owning org, by name
scopes: [posts:read, posts:write]
client_id: client_local_backend # optional; generated if omitted
client_secret: secret_local_backend # optional; generated if omitted
audience: https://api.acme.example # optional; the token `aud` claim, defaults to client_idEach seeded application is provisioned with a client secret. Pin client_secret to bake a known
value into a service's environment; otherwise one is generated. The application is then available
via GET /connect/applications.
A service swaps its seeded client_id + client_secret for a scoped access token at
POST /oauth2/token, exactly as 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
# (client credentials may also be sent via HTTP Basic auth){
"access_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "posts:read posts:write"
}The access_token is an RS256 JWT signed with the same key the emulator publishes at
GET /sso/jwks/:client_id (and GET /oauth2/jwks), so a consumer validating with JWKS — e.g.
jose — verifies it with no emulator-specific shims. Its claims:
| Claim | Value |
|---|---|
iss |
the emulator base URL (e.g. http://localhost:4100) |
aud |
the app's audience if set, otherwise the client_id |
sub |
the requesting client_id |
scp |
granted scopes (array) |
org_id |
the application's owning organization |
Set audience on the seeded application to match what your real WorkOS environment emits, so a
consumer that validates the aud claim accepts emulator tokens unchanged.
A request may narrow to a subset of the application's scopes via -d scope="posts:read";
requesting a scope the application does not have returns 400 invalid_scope, so scope-based
authorization can be exercised locally. Unknown credentials return 401 invalid_client, and an
oauth-type application returns 400 unauthorized_client.
Seed organization- or user-owned API keys. Each seeded key is created as an api_key resource
and registered in the auth allow-list, so the value authenticates requests to the emulator.
organizations:
- name: Acme Corp
apiKeys:
- name: CI Key
organization: Acme Corp # owner org, by name (or use `user_id`)
value: sk_test_ci_key # optional; must start with `sk_`; generated if omitted
permissions: [posts:read, posts:write]
# expires_at: 2030-01-01T00:00:00.000Z # optional; never expires if omitted# The seeded value authenticates requests:
curl http://localhost:4100/connect/applications -H "Authorization: Bearer sk_test_ci_key"The organization (or the org supplied via user_id) must reference a seeded organization;
an unresolved name fails fast at startup. A key seeded with an already-past expires_at is still
created as a resource but does not authenticate, and deleting a key via DELETE /api_keys/:id
stops it authenticating immediately — matching production.
apiKeys also accepts the legacy auth allow-list map form ({ sk_xxx: { environment } }), which
only registers values for authentication without creating resources.
The emulator implements the full workos.com/docs login story: every resource creation and authentication outcome fires a signed webhook, with event names and payload shapes generated from the WorkOS OpenAPI spec. You can run your app's entire login flow — hosted authorize, callback, token exchange, webhook handling — against the emulator without touching the real API.
Seed it (an empty events list subscribes to everything):
webhookEndpoints:
- endpoint_url: http://localhost:5005/webhooks
events: []Or register at runtime and choose your own signing secret:
curl -X POST http://localhost:4100/webhook_endpoints \
-H "Authorization: Bearer sk_test_default" \
-H "Content-Type: application/json" \
-d '{"endpoint_url":"http://localhost:5005/webhooks","secret":"whsec_test","events":[]}'Point your SDK's base URL at the emulator and follow the AuthKit quickstart exactly as documented:
- Create a user —
POST /user_management/users→ auser.createdwebhook arrives. - Redirect to AuthKit — send the browser to
GET /user_management/authorize?redirect_uri=...&state=.... By default the emulator immediately redirects back to your callback with acode; with--interactiveit serves a real login page first. - Exchange the code — your callback calls
POST /user_management/authenticatewithgrant_type=authorization_code. You get back the user,access_token, andrefresh_token— andsession.createdplusauthentication.oauth_succeededwebhooks arrive. - Other methods work the same way — password, Magic Auth, email verification, MFA, and SSO logins all emit their spec-named
authentication.*_succeededevents; failed attempts emitauthentication.*_failedwith anerror: { code, message }object.
Codes that WorkOS would deliver by email are delivered to you in the webhook payload instead: magic_auth.created carries the Magic Auth code, password_reset.created carries the reset token, and email_verification.created carries the verification code. Your test can drive the whole flow from webhooks alone — see src/e2e.spec.ts for a complete worked example.
Webhooks are signed exactly like production WorkOS: WorkOS-Signature: t=<timestamp>,v1=<hmac> where the HMAC-SHA256 is computed over "{timestamp}.{body}" with the endpoint's secret. The official SDKs' webhooks.constructEvent verifies them unchanged.
Authentication events carry the spec payload { type, status, user_id, email, ip_address, user_agent } (plus error on failures and sso details on SSO events).
| Trigger | Events |
|---|---|
| Login success (per method) | authentication.{oauth,password,magic_auth,email_verification,mfa,sso}_succeeded |
| Login failure (bad/expired credential) | authentication.{oauth,password,magic_auth,email_verification,mfa,sso}_failed |
| Sessions | session.created, session.revoked |
| Users | user.created, user.updated, user.deleted |
| Login-flow resources | magic_auth.created, email_verification.created, password_reset.created, password_reset.succeeded |
| Organizations & domains | organization.*, organization_domain.* (incl. organization_domain.verified) |
| Memberships & invitations | organization_membership.*, invitation.{created,accepted,revoked,resent} |
| Connections | connection.activated, connection.deactivated, connection.deleted |
| Directory Sync | dsync.activated, dsync.deleted, dsync.user.*, dsync.group.* |
| Roles & permissions | role.*, organization_role.*, permission.* |
| API keys & feature flags | api_key.{created,updated,revoked}, flag.{created,updated,deleted} |
The full catalog (including names the emulator never emits, like authentication.passkey_* and vault.*) lives in src/workos/generated/events.ts, generated from the @workos/openapi-spec package.
All events are also queryable at GET /events (filter with ?events[]=user.created).
- Delivery is fire-and-forget with a 5-second timeout and no retries — poll your receiver in tests rather than asserting immediately.
- Resources defined in a seed file record events (visible at
GET /events) but are not delivered to webhook endpoints from the same seed file — endpoints are registered last, mirroring real WorkOS, where pre-existing data never replays. Register endpoints via the API if you want deliveries for setup data. dsync.group.user_added/dsync.group.user_removedare catalogued but never emitted: the emulator has no directory group membership mutation surface.
Error hooks let you force the emulator to return non-200 responses so you can test how your app handles WorkOS API failures (422, 500, etc.).
Add errorHooks to your config file:
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| Field | Required | Description |
|---|---|---|
method |
yes | HTTP method to match (GET, POST, etc.) or * for any |
path |
yes | URL path to match — exact (/user_management/users), prefix with wildcard (/user_management/*), or * for all |
status |
yes | HTTP status code to return |
body |
no | Custom JSON response body (message, code, errors). A sensible default is used when omitted. |
count |
no | Number of times the hook fires before it auto-removes. Omit for unlimited. |
Manage hooks at runtime without restarting the emulator. These endpoints require no authentication.
# List all hooks
curl http://localhost:4100/_emulate/hooks
# Add a hook
curl -X POST http://localhost:4100/_emulate/hooks \
-H "Content-Type: application/json" \
-d '{"method":"GET","path":"/user_management/users","status":500}'
# Remove a hook by ID
curl -X DELETE http://localhost:4100/_emulate/hooks/hook_abc123const emulator = await createEmulator({ port: 0 });
// Make user creation return a 422
const hook = emulator.addErrorHook({
method: 'POST',
path: '/user_management/users',
status: 422,
body: { message: 'Email is invalid', code: 'unprocessable_entity' },
});
// Your app code under test handles the error...
// Clean up
emulator.removeErrorHook(hook.id);
// Or list what's active
emulator.listErrorHooks();
// reset() clears all hooks and re-seeds from the original config
emulator.reset();Error hooks can be used for sophisticated testing scenarios:
const emulator = await createEmulator({ port: 0 });
// Make the first 3 requests fail, then succeed
emulator.addErrorHook({
method: 'POST',
path: '/user_management/users',
status: 503,
count: 3, // Auto-remove after 3 uses
});
// Your app's retry logic will handle the failures
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);
// Attempt 1-3: 503, Attempt 4: 201
}// Simulate validation errors for specific inputs
emulator.addErrorHook({
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' },
{ field: 'password', code: 'too_short', message: 'must be at least 8 characters' },
],
},
});// Simulate rate limiting after 10 requests
let requestCount = 0;
emulator.addErrorHook({
method: '*',
path: '/user_management/*',
status: 429,
body: {
message: 'Rate limit exceeded',
code: 'rate_limit_exceeded',
},
count: 1, // Will be managed manually
});
// In your test, manage the hook manually
const checkRateLimit = async () => {
requestCount++;
if (requestCount > 10) {
// Add the rate limit hook
emulator.addErrorHook({
method: '*',
path: '/user_management/*',
status: 429,
});
}
};Custom seeding can be used to create complex test scenarios:
users:
- email: admin@acme.com
first_name: Admin
last_name: User
password: admin123
email_verified: true
- email: employee@acme.com
first_name: Regular
last_name: Employee
password: employee123
email_verified: true
organizations:
- name: Acme Corp
external_id: acme_corp_123
domains:
- domain: acme.com
state: verified
memberships:
# Reference users by the email declared in `users` above — user ids are
# generated at startup, so memberships are joined by email.
- email: admin@acme.com
role: admin
status: active
- email: employee@acme.com
role: member
status: active
roles:
- slug: admin
name: Administrator
description: Full access to all resources
permissions: [users:read, users:write, organizations:read, organizations:write]
- slug: member
name: Member
description: Standard access
permissions: [users:read, organizations:read]
permissions:
- slug: users:read
name: Read Users
- slug: users:write
name: Write Users
- slug: organizations:read
name: Read Organizations
- slug: organizations:write
name: Write Organizations
connections:
- name: Acme SSO
connection_type: GenericSAML
organization: Acme Corp
state: active
domains: [acme.com]
profiles:
- email: admin@acme.com
first_name: Admin
last_name: User
groups: [admins, it_staff]
- email: employee@acme.com
first_name: Regular
last_name: Employee
groups: [employees]
webhookEndpoints:
- endpoint_url: http://localhost:5000/webhooks
events: []
enabled: trueBy default, the SSO and AuthKit authorize endpoints auto-redirect with an auth code — great for API-level tests, but agent browsers and E2E test frameworks need an actual login page to interact with.
Pass --interactive (CLI) or interactiveAuth: true (programmatic) to enable login pages:
workos-emulate --interactive --seed workos-emulate.config.yamlconst emulator = await createEmulator({
interactiveAuth: true,
seed: {
users: [{ email: 'test@example.com', password: 'secret' }],
connections: [{ name: 'Test SSO', organization: 'Acme', domains: ['example.com'] }],
organizations: [{ name: 'Acme' }],
},
});| Endpoint | Default (auto) | Interactive |
|---|---|---|
GET /sso/authorize |
Immediately redirects to callback with auth code | Serves an HTML login page with an email field |
GET /user_management/authorize |
Immediately redirects to callback with auth code | Serves an HTML login page with an email field |
When interactive mode is on:
- Your app redirects to
/sso/authorize?connection=...&redirect_uri=...(or/user_management/authorize?...) - The emulator serves a login page instead of auto-redirecting
- The browser (or agent) fills in the email field and submits the form
- The emulator creates an auth code and redirects back to your app's callback URL
The login_hint parameter pre-fills the email field, so agent browsers can skip typing if desired.
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/);
});This replaces the need for WorkOS's Test Identity Provider — no dashboard login required, works in incognito, works with headless browsers.
The WorkOS Emulator is designed for testing and development environments. When using it in production-like scenarios, consider the following security implications:
- No real authentication: The emulator uses a simple API key system (
sk_test_default) that provides no real security. Anyone with access to the emulator can make API calls. - No rate limiting: By default, the emulator has no rate limiting. In production scenarios, implement rate limiting to prevent abuse.
- Public error hook endpoints: The
/_emulate/hooksendpoints require no authentication and can be used by anyone who can reach the server.
- In-memory storage: All data is stored in memory and lost when the server stops. Do not use for persistent data.
- No encryption: Data is not encrypted at rest or in transit. Use HTTPS in production environments.
- No audit logging: While the emulator has audit log endpoints, it doesn't provide real security auditing.
- Simple signature verification: Webhook signatures use HMAC-SHA256, but ensure your webhook endpoints validate signatures properly.
- No webhook authentication: The emulator doesn't authenticate webhook endpoints — ensure your endpoints are secure.
- Bind to localhost: By default, the emulator binds to
localhost, so its unauthenticated endpoints are only reachable from the local machine. To intentionally expose it to other hosts, pass--host 0.0.0.0(CLI) orhostname: '0.0.0.0'(createEmulator), and protect it with a firewall or VPN. - No CORS restrictions: The emulator doesn't enforce CORS. Configure CORS in your application if needed.
- No TLS/SSL: The emulator doesn't provide HTTPS. Use a reverse proxy (nginx, Caddy) for TLS termination in production.
- Use only in development/testing: The emulator is not designed for production use.
- Run behind a reverse proxy: Use nginx, Caddy, or similar for TLS termination and additional security.
- Implement authentication: Add proper authentication if exposing the emulator to external networks.
- Use environment variables: Store sensitive configuration (API keys, secrets) in environment variables.
- Regular updates: Keep the emulator updated to get security fixes and improvements.
- Network isolation: Run the emulator in an isolated network environment when possible.