workos-migrate-stytch
WorkOS Migration: Stytch
Step 1: Fetch Documentation (BLOCKING)
STOP. Do not proceed until complete.
WebFetch: https://workos.com/docs/migrate/stytch
The documentation is the source of truth. If this skill conflicts with the docs, follow the docs.
Step 2: Pre-Migration Planning (Decision Tree)
User Type Detection
Stytch user type?
|
+-- B2B Users --> Follow this skill (organizations + members)
|
+-- Consumer Users --> Use Stytch's export utility:
https://github.com/stytchauth/stytch-node-export-users
This skill covers B2B user migration only. Consumer users require different export logic.
Authentication Method Inventory
Check your Stytch dashboard to determine which auth methods are in use:
- Password auth → Contact Stytch support for hash export (Timeline: varies, plan ahead)
- Magic Link → Maps to WorkOS Magic Auth (6-digit code vs. clickable link)
- Email OTP → Direct mapping to Magic Auth (no changes needed)
- OAuth (Google, Microsoft, GitHub) → Direct mapping to WorkOS OAuth
Critical: If using password auth, contact support@stytch.com NOW to request hash export. This has variable turnaround time and will block final import.
Step 3: Environment Setup
Required Credentials
Create .env.local with:
# Stytch (for export)
STYTCH_PROJECT_ID=project-test-xxx
STYTCH_SECRET=secret-test-xxx
# WorkOS (for import)
WORKOS_API_KEY=sk_test_xxx
WORKOS_CLIENT_ID=client_xxx
SDK Installation
Detect package manager, install both SDKs:
# Stytch for export
npm install stytch
# WorkOS for import
npm install @workos-inc/node
Verify: Both packages exist in node_modules before continuing.
Step 4: Export from Stytch (BLOCKING)
Export Organizations
Create export-stytch.ts:
import { B2BClient } from 'stytch';
import { writeFile } from 'fs/promises';
const client = new B2BClient({
project_id: process.env.STYTCH_PROJECT_ID!,
secret: process.env.STYTCH_SECRET!,
});
async function exportOrganizations() {
const orgs = [];
let cursor: string | undefined;
do {
const response = await client.organizations.search({
cursor,
limit: 1000,
});
orgs.push(...response.organizations);
cursor = response.has_more ? response.next_cursor : undefined;
} while (cursor);
await writeFile('stytch-orgs.json', JSON.stringify(orgs, null, 2));
return orgs;
}
Rate limit: 100 requests/minute. For large datasets, add delays between batches.
Export Members
For each organization, export members:
async function exportMembers(orgId: string) {
const members = [];
let cursor: string | undefined;
do {
const response = await client.organizations.members.search({
organization_ids: [orgId],
cursor,
limit: 1000,
});
members.push(...response.members);
cursor = response.has_more ? response.next_cursor : undefined;
} while (cursor);
return members;
}
async function exportAllMembers(orgs: any[]) {
const allMembers = [];
for (const org of orgs) {
const members = await exportMembers(org.organization_id);
allMembers.push(...members);
}
await writeFile('stytch-members.json', JSON.stringify(allMembers, null, 2));
}
Run export:
tsx export-stytch.ts
Verify: Files stytch-orgs.json and stytch-members.json exist and contain data.
Password Hash Export (If Using Password Auth)
This is a MANUAL process:
- Email
support@stytch.comwith subject "Password Hash Export Request" - Provide your Stytch project ID
- Wait for hash file delivery (timeline varies)
- Critical: Verify the hash algorithm they provide. Ask specifically: "What hashing algorithm was used?" (Stytch uses
scryptbut confirm format matches WorkOS expectations)
Do not proceed to Step 5 if passwords are in use and you don't have the hash export.
Step 5: Import to WorkOS
Import Organizations First
Create import-workos.ts:
import { WorkOS } from '@workos-inc/node';
import { readFile } from 'fs/promises';
const workos = new WorkOS(process.env.WORKOS_API_KEY);
async function importOrganization(stytchOrg: any) {
const domainData = stytchOrg.email_allowed_domains?.map((domain: string) => ({
domain,
state: 'verified', // Adjust based on Stytch verification state
}));
const org = await workos.organizations.createOrganization({
name: stytchOrg.organization_name,
domainData,
idempotencyKey: `stytch-${stytchOrg.organization_id}`, // Prevent duplicates
});
return { stytchId: stytchOrg.organization_id, workosId: org.id };
}
async function importAllOrganizations() {
const orgsData = await readFile('stytch-orgs.json', 'utf-8');
const orgs = JSON.parse(orgsData);
const mapping = [];
for (const org of orgs) {
const result = await importOrganization(org);
mapping.push(result);
}
await writeFile('org-mapping.json', JSON.stringify(mapping, null, 2));
}
Critical: Save the Stytch → WorkOS organization ID mapping. You need it for member imports.
Import Users and Memberships
async function importUser(stytchMember: any, orgMapping: any[]) {
// Filter by status - only import active members
if (stytchMember.status !== 'active') {
console.log(`Skipping non-active member: ${stytchMember.email_address}`);
return null;
}
// Parse name
const nameParts = stytchMember.name?.split(' ') || [];
const firstName = nameParts[0] || '';
const lastName = nameParts.slice(1).join(' ') || '';
// Create user (without password for now)
const user = await workos.userManagement.createUser({
email: stytchMember.email_address,
emailVerified: stytchMember.email_address_verified ?? false,
firstName,
lastName,
idempotencyKey: `stytch-user-${stytchMember.member_id}`,
});
// Create organization membership
const workosOrgId = orgMapping.find(
m => m.stytchId === stytchMember.organization_id
)?.workosId;
if (workosOrgId) {
await workos.userManagement.createOrganizationMembership({
userId: user.id,
organizationId: workosOrgId,
});
}
return user;
}
Decision: Invited/Pending Members
Member status?
|
+-- active --> Import directly
|
+-- invited --> Option A: Skip and re-invite via WorkOS
| --> Option B: Import and send new invite
|
+-- pending --> Same as invited
Choose your strategy before running the import loop.
Import Password Hashes (If Available)
Only proceed if you have the hash export from Stytch support.
async function importUserWithPassword(stytchMember: any, passwordHash: string) {
const user = await workos.userManagement.createUser({
email: stytchMember.email_address,
emailVerified: stytchMember.email_address_verified ?? false,
firstName,
lastName,
passwordHash: passwordHash,
passwordHashType: 'scrypt', // Confirm with Stytch support
idempotencyKey: `stytch-user-${stytchMember.member_id}`,
});
return user;
}
Critical: The passwordHashType parameter must match the algorithm Stytch used. Verify this with their support team. WorkOS supports:
scryptbcryptargon2
If the format is wrong, users cannot sign in and you'll need to force password resets.
Step 6: Configure WorkOS Dashboard
Enable Authentication Methods
Navigate to WorkOS Dashboard → Authentication:
-
Password Auth (if migrating passwords):
- Enable "Email + Password"
- Configure password requirements to match or exceed Stytch's
-
Magic Auth (replaces Stytch Magic Link / Email OTP):
- Enable "Magic Auth"
- Note: Users get 6-digit code (not clickable link)
- Codes expire after 10 minutes
-
OAuth Providers (if migrating social logins):
- Navigate to Authentication → OAuth
- Enable each provider (Google, Microsoft, GitHub, etc.)
- Configure client credentials for each
- Users will auto-link by email address
Critical: WorkOS Magic Auth sends a CODE, not a clickable link. Update user-facing documentation to reflect this UX change.
Step 7: SDK Integration (If Not Already Done)
If this is a NEW WorkOS integration (not just data migration), implement AuthKit:
- See
workos-authkit-nextjsskill for Next.js - See
workos-authkit-reactskill for React - See
workos-authkit-vanilla-jsskill for plain JavaScript
This step is BLOCKING if you don't have WorkOS auth flows implemented yet.
Verification Checklist (ALL MUST PASS)
Run these checks after import completes:
# 1. Verify export files exist
ls stytch-orgs.json stytch-members.json org-mapping.json
# 2. Count imported organizations (should match Stytch count)
cat org-mapping.json | jq 'length'
# 3. Test user login (pick a test user)
# Manual: Go to WorkOS dashboard → Users → Try signing in as test user
# 4. Verify OAuth providers configured (if applicable)
# Manual: Dashboard → Authentication → OAuth → Check each provider shows "Configured"
# 5. Check password auth enabled (if migrated hashes)
# Manual: Dashboard → Authentication → Email + Password → Should show "Enabled"
Critical password verification:
# Test a user with migrated password hash
curl -X POST https://api.workos.com/user_management/authenticate \
-H "Authorization: Bearer $WORKOS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"client_id": "'"$WORKOS_CLIENT_ID"'",
"email": "test@example.com",
"password": "test-password",
"grant_type": "password"
}'
# Expected: 200 with access_token
# If 401: Hash format mismatch - verify passwordHashType with Stytch
Error Recovery
"Stytch API rate limit exceeded"
Root cause: Exceeded 100 requests/minute during export.
Fix: Add rate limiting to export script:
async function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Add after each API call
await sleep(650); // ~90 requests/minute with buffer
"Invalid password hash format"
Root cause: passwordHashType doesn't match actual Stytch hash algorithm.
Fix:
- Contact Stytch support: "What hashing algorithm and parameters were used?"
- Update
passwordHashTypein import script - Re-run import for affected users OR force password resets
"Organization already exists" during import
Root cause: Re-running import without idempotency keys.
Fix:
- Check
org-mapping.jsonfor existing Stytch → WorkOS ID pairs - Skip organizations already in mapping file
- Always use
idempotencyKeyparameter to prevent duplicates
"Email already exists" during user import
Root cause: User already imported or email collision.
Fix:
try {
const user = await workos.userManagement.createUser({...});
} catch (error) {
if (error.code === 'user_already_exists') {
// Fetch existing user by email and create membership only
const users = await workos.userManagement.listUsers({ email: stytchMember.email_address });
const existingUser = users.data[0];
await workos.userManagement.createOrganizationMembership({
userId: existingUser.id,
organizationId: workosOrgId,
});
} else {
throw error;
}
}
"User cannot sign in after migration"
Decision tree for diagnosis:
Sign-in method?
|
+-- Password --> Check passwordHashType matches Stytch algorithm
| --> Verify emailVerified = true (WorkOS requires verified emails)
|
+-- Magic Auth --> No migration needed - works immediately
|
+-- OAuth --> Check provider configured in dashboard
--> Verify email matches between Stytch and OAuth account
"Members missing from imported organizations"
Root cause: Status filtering excluded invited/pending members.
Fix:
- Review filtering logic in
importUserfunction - Decide on invited/pending strategy (re-invite vs. import)
- Re-run member import with adjusted status filter
Stytch environment variables not loading
Fix:
# Verify .env.local exists and has correct format
cat .env.local | grep STYTCH
# For tsx, ensure dotenv is configured
tsx --env-file=.env.local export-stytch.ts
Related Skills
workos-authkit-nextjs- Integrate AuthKit with Next.js after migrationworkos-authkit-react- Integrate AuthKit with React after migrationworkos-magic-link- Configure Magic Auth to replace Stytch magic linksworkos-api-authkit- Direct API usage for custom auth flowsworkos-api-organization- Advanced organization management post-migration
More from workos/skills
workos
Use when the user asks for a WorkOS docs URL, term, or dashboard field (Sign-in endpoint, initiate_login_uri, Redirect URI, `WORKOS_*` env vars), or is implementing, debugging, or migrating WorkOS — AuthKit, SSO/SAML, Directory Sync, RBAC, FGA, MFA, Vault, Audit Logs, Admin Portal, Pipes (Connected Apps), Feature Flags, Radar (bot/fraud detection), webhooks, Custom Domains, or migrating from Auth0, Clerk, Cognito, Firebase, Supabase, Stytch, Descope, or Better Auth. Also triggers on @workos-inc/* imports.
595workos-widgets
Use when the user is implementing, embedding, or debugging a WorkOS Widget — specifically the User Management, User Profile, Admin Portal SSO Connection, or Admin Portal Domain Verification widgets. Handles the full stack — detecting the frontend (Next.js, React, React Router, TanStack Start, Vite, SvelteKit), generating access tokens via the backend SDK in use (Node, Python, Go, Ruby, PHP, Java, .NET), and wiring up the widget component correctly per the bundled OpenAPI spec. Also use when code imports from @workos-inc/widgets or the user pastes <UserManagement /> or <UserProfile /> JSX.
270workos-authkit-nextjs
Integrate WorkOS AuthKit with Next.js App Router (13+). Server-side rendering required.
64workos-authkit-base
Architectural reference for WorkOS AuthKit integrations. Fetch README first for implementation details.
42workos-authkit-react
Integrate WorkOS AuthKit with React single-page applications. Client-side only authentication. Use when the project is a React SPA without Next.js or React Router.
33workos-authkit-tanstack-start
Integrate WorkOS AuthKit with TanStack Start applications. Full-stack TypeScript with server functions. Use when project uses TanStack Start, @tanstack/start, or vinxi.
28