Migrating from WorkOS
WorkOS is the most enterprise-shaped of our competitors. If you’re here, you probably have multi-tenant SSO, SAML connections per customer, SCIM directory sync, an audit-log compliance story, and maybe AuthKit dropped on top. Synq covers the same surface, with the same enterprise-grade primitives, and the migration is straightforward once the concept map lines up.
This guide focuses on what changes vs WorkOS specifically. For the
SDK ergonomics (createSynq, verifySession, <SynqProvider>),
read the Next.js SDK page — most of the day-to-day
code shape is identical to AuthKit’s Next.js helpers.
TL;DR — the seven moves
pnpm add @synqid/nextjs @synqid/jsandpnpm remove @workos-inc/node @workos-inc/authkit-nextjs.- WorkOS Organization → Synq Org. WorkOS Connection → Synq Brand Connection.
- Replace AuthKit’s catch-all with
app/api/auth/[...synq]/route.ts. - Replace
withAuth()withverifySession()server-side. - Move SAML / OIDC connection configs from WorkOS dashboard to Synq dashboard (per-Brand connections).
- Replace WorkOS Directory Sync with Synq Directory API + webhooks.
- Switch backend token verification from WorkOS JWKS to Synq JWKS.
Concept mapping
| WorkOS | Synq | Notes |
|---|---|---|
| Organization | Org | Developer-facing entity. Members + roles + billing + API keys. |
| Connection (SSO / SAML / OIDC) | Brand Connection | Configured per-Brand. Each Brand can have multiple connections. |
| Directory (SCIM) | Synq Directory API + webhooks | See “Directory sync” below. |
| AuthKit (hosted UI + Next.js helper) | Brand sign-in surface + @synqid/nextjs | Hosted UI is themed by Brand settings. |
| Domains | Brand allowed-email domains | Configured under Brand → Connections → restrict-by-domain. |
| Roles | Product-side enum + requireAccess | Synq does not model permissions. See “Roles” below. |
| Magic Auth | Magic-link connection on the Brand | Native support, no extra code. |
| Audit Log Events | Synq audit log (GET /audit-log/events) | Similar shape, retention bound by tier. See Audit log. |
| Webhooks | Webhooks | See Webhooks. HMAC-signed, retried, dedupable. |
| Events API (polling) | Webhooks | Synq does not expose a polling stream today (near-term roadmap). |
| Admin Portal (org admin self-service) | Brand admin dashboard | Org admins use the Synq dashboard directly. White-labeled per-tenant Admin Portal is on the roadmap. |
| User Management API | Synq REST API | See API reference. |
| Vault (encrypted user metadata) | Use your own product DB | Synq does not store opaque product metadata. |
| FGA (Fine-Grained Authorization) | Product-side authz layer + requireAccess | Use Cerbos, OPA, Permit.io, or hand-rolled. |
| Multi-region (US / EU / AU) | Single region today | See “Multi-region” below. |
WorkOS aimed for the broadest enterprise feature surface; Synq aims for the cleanest OIDC core with explicit extension points (webhooks, custom claims, Brand connections). Expect to keep your product-side authz layer; the rest is renames.
Step 1 — install
pnpm remove @workos-inc/node @workos-inc/authkit-nextjs
pnpm add @synqid/nextjs @synqid/jsNeed a bare API client (no Next.js)? Add @synqid/js only.
Env var mapping:
| WorkOS | Synq |
|---|---|
WORKOS_API_KEY | SYNQ_CLIENT_SECRET (per App) |
WORKOS_CLIENT_ID | SYNQ_CLIENT_ID |
WORKOS_COOKIE_PASSWORD | SESSION_SECRET |
NEXT_PUBLIC_WORKOS_REDIRECT_URI | (none — derived from APP_URL) |
WORKOS_HOSTED_DOMAIN (per-Org) | (none — Brand handles theming) |
Step 2 — lib/synq.ts
Standard setup; nothing WorkOS-specific:
// src/lib/synq.ts
import { createSynq } from '@synqid/nextjs/server'
export const synqConfig = {
clientId: process.env.SYNQ_CLIENT_ID!,
clientSecret: process.env.SYNQ_CLIENT_SECRET!,
appUrl: process.env.APP_URL!,
sessionSecret: process.env.SESSION_SECRET!,
}
export const { verifySession, getUser, requireGuest, requireAccess, auth } =
createSynq(synqConfig)Step 3 — the route handler
Before — app/api/auth/[...workos]/route.ts (or similar):
import { handleAuth } from '@workos-inc/authkit-nextjs'
export const GET = handleAuth({ returnPathname: '/dashboard' })After — app/api/auth/[...synq]/route.ts:
import { handlers } from '@synqid/nextjs/handlers'
import { synqConfig } from '@/lib/synq'
export const { GET, POST } = handlers(synqConfig)returnPathname becomes a ?returnTo= query param on the login link.
Step 4 — server-side reads
Before:
import { withAuth } from '@workos-inc/authkit-nextjs'
const { user } = await withAuth({ ensureSignedIn: true })After:
import { verifySession } from '@/lib/synq'
const { user } = await verifySession()Soft variant (signed-out is OK):
import { getUser } from '@/lib/synq'
const user = await getUser()The session shape:
interface SynqSession {
user: SynqUser
accessToken: string
idToken?: string
expiresAt: number
scopes?: string[]
}accessToken carries the aud claim as the Brand slug, not the
Client ID. See Sessions and tokens.
Step 5 — provider + proxy
// src/app/layout.tsx
import { getUser } from '@/lib/synq'
import { SynqProvider } from '@synqid/nextjs/client'
export default async function RootLayout({ children }) {
const user = await getUser()
return (
<html><body>
<SynqProvider user={user}>{children}</SynqProvider>
</body></html>
)
}// src/proxy.ts
import { synqProxy } from '@synqid/nextjs/proxy'
export const proxy = synqProxy({
publicPaths: ['/', '/pricing', '/api/public/*'],
})
export const config = {
matcher: ['/((?!api/auth|_next/static|_next/image|favicon.ico).*)'],
}Connections — SAML, OIDC, Google Workspace, Microsoft
This is where WorkOS-shaped apps spend the most time. The good news: Synq supports the same connection types, configured per-Brand.
| WorkOS connection type | Synq equivalent |
|---|---|
| SAML 2.0 | Brand → Connections → SAML |
| OIDC (generic) | Brand → Connections → OIDC |
| Google Workspace | Brand → Connections → Google (BYO credentials) |
| Microsoft Entra (Azure AD) | Brand → Connections → Microsoft (BYO) |
| Apple | Brand → Connections → Apple (BYO) |
| Okta (via SAML / OIDC) | Brand → Connections → SAML or OIDC |
| OneLogin | Brand → Connections → SAML |
| ADFS | Brand → Connections → SAML |
| Magic Auth | Brand → Connections → Magic Link |
The setup ritual is the same:
- Add the connection in the Synq dashboard.
- Synq emits an ACS URL + entity ID (for SAML) or a redirect URI (for OIDC).
- You hand those to the IdP admin.
- IdP admin hands back metadata.xml or
client_id+client_secret. - You paste them into Synq.
If you’re moving an existing SAML connection mid-traffic, you can add it to Synq, test with a test IdP, then point the IdP at Synq’s new ACS URL. The cutover is the IdP-side ACS URL change.
Per-tenant SAML — one Brand per customer
WorkOS’s enterprise pattern: one Organization per customer, one SAML Connection per customer’s IdP. The Synq pattern:
- Either one Org per customer with one Brand per customer (full isolation; per-tenant theming),
- or a single shared Brand with N SAML connections (lower setup cost; less isolation).
For enterprise SSO with branded login screens, the one Org per customer + one Brand per customer topology is the right call. It’s also how WorkOS Organizations were typically used. Set up via the Synq Admin API:
import { SynqAdminClient } from '@synqid/js'
const synq = new SynqAdminClient({ apiKey: process.env.SYNQ_ADMIN_API_KEY! })
// On tenant signup:
const org = await synq.orgs.create({
slug: tenantSlug,
name: tenantName,
})
const brand = await synq.brands.create({
org: org.id,
slug: tenantSlug,
name: tenantName,
primaryColor: tenantColor,
})
const samlConnection = await synq.connections.createSaml({
brand: brand.id,
metadataXml: tenantSamlMetadata,
})
const app = await synq.apps.create({
brand: brand.id,
type: 'web',
redirectUris: [`https://${tenantSlug}.acme.com/api/auth/callback`],
})
// Hand app.clientId + app.clientSecret to the tenant's deployment.The Admin API surface is in API reference. Read Organizations and Brands for the underlying model.
Directory sync (SCIM)
WorkOS Directory Sync gives you a normalized API over Okta / Azure SCIM. Synq exposes a Directory API + webhooks instead.
The shape:
- Webhooks —
member.added,member.updated,member.removedfire when the IdP pushes a SCIM event. You handle each event to keep your local membership cache in sync. - Directory API —
GET /identity/organizations/<orgSlug>/directory/usersexposes the synced user list with paging.
Event mapping:
| WorkOS Directory event | Synq webhook |
|---|---|
dsync.user.created | member.added |
dsync.user.updated | member.updated |
dsync.user.deleted | member.removed |
dsync.group.created | group.created |
dsync.group.updated | group.updated |
dsync.group.deleted | group.deleted |
dsync.group.user_added | group.memberAdded |
dsync.group.user_removed | group.memberRemoved |
Webhook handler skeleton:
// src/app/api/synq/webhook/route.ts
import { headers } from 'next/headers'
import { createHmac, timingSafeEqual } from 'node:crypto'
const SECRET = process.env.SYNQ_WEBHOOK_SECRET!
export async function POST(req: Request) {
const raw = await req.text()
const h = await headers()
const sig = h.get('Synq-Signature')!
const expected = createHmac('sha256', SECRET).update(raw).digest('hex')
if (!timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return new Response('bad sig', { status: 401 })
}
const event = JSON.parse(raw)
switch (event.type) {
case 'member.added': await provisionUser(event.data); break
case 'member.removed': await deprovisionUser(event.data); break
// ...
}
return new Response('ok')
}The full pattern is in Webhooks.
Roles → product-side enum + requireAccess
WorkOS Roles attach a string to each Organization Membership and
project it into your access token as role. Synq’s stance: roles
are your product’s concept, not Synq’s.
The pattern:
// In a server component:
import { requireAccess } from '@/lib/synq'
import { yourRbacClient } from '@/lib/rbac'
const { user } = await requireAccess(async ({ user }) => {
return await yourRbacClient.can(user.sub, 'billing:manage', { org: orgSlug })
})You bring the RBAC engine. Synq invokes the predicate, reads the
boolean, redirects on false. We recommend Cerbos / Permit.io / OPA
for anything non-trivial; a hand-rolled permissions table works for
simple shapes.
If you absolutely want a role in the token (legacy backend can’t change), configure a custom claim per-Brand in the dashboard that pulls the role from your product DB. The claim lands on every minted token. Same shape as WorkOS, opt-in instead of default.
Audit log
WorkOS’s Audit Log Events API supports a polling pattern with
X-WorkOS-Last-Event-ID resume. Synq’s audit log is at
GET /audit-log/events with cursor pagination, plus an
auditLog.event webhook subscription for push.
| WorkOS endpoint | Synq endpoint |
|---|---|
POST /audit_logs/events (write) | (Synq writes its own audit log; not user-writable today) |
GET /audit_logs/exports/:id (export) | POST /audit-log/exports → GET /audit-log/exports/:id |
GET /audit_logs/events | GET /organizations/:slug/audit-log/events |
Retention is tier-bound. See Audit log for
the per-tier retention windows and event taxonomy. If your compliance
posture needs unbounded retention, subscribe to the
auditLog.event webhook and ship to your own log store (Datadog,
Loki, S3).
Magic Link
WorkOS Magic Auth is a separate API call. Synq’s magic link is a Brand connection — enable it on the Brand and a “Continue with email” button shows up on the hosted login surface. No additional code.
If you want a programmatic magic-link send (e.g. invite emails), use the Admin API:
await synq.invites.send({
org: orgId,
email: 'alex@acme.com',
// Optional: pre-link to a role you'll honor product-side
metadata: { role: 'admin' },
})The user clicks the link, signs in via Synq’s hosted surface, lands
on your app authenticated. Synq fires member.added when they join
the Org.
Webhooks — event name mapping
The full WorkOS → Synq event map for events not already covered:
| WorkOS | Synq |
|---|---|
connection.activated | connection.activated |
connection.deactivated | connection.deactivated |
connection.deleted | connection.deleted |
organization.created | (no equivalent — Synq Orgs are developer entities, not customer tenants) |
organization.updated | org.updated (for your own Org, not customer tenants) |
organization_membership.created | member.added |
organization_membership.updated | member.updated |
organization_membership.deleted | member.removed |
user.created | member.added (fires when they land in an Org) |
user.updated | member.updated |
user.deleted | (Synq deletes are rare; see “Right to be forgotten” in Audit log) |
session.created | consent.granted (first sign-in to an App) |
email_verification.created | (no equivalent) |
email_verification.succeeded | (no equivalent) |
Multi-region
WorkOS offers US / EU / AU residency. Synq is single-region today (US), with EU on the near-term roadmap. If data residency is a contract requirement for you, write to support@synq.id — we can flag the timeline and coordinate on the cutover.
In the meantime: Synq encrypts at rest, isolates per-Brand credentials, signs every audit-log row, and exposes the full audit log via the API for self-managed exports.
A complete diff — server-side token verification
Before — lib/auth.ts using @workos-inc/node:
import { WorkOS } from '@workos-inc/node'
const workos = new WorkOS(process.env.WORKOS_API_KEY!)
export async function verifyToken(accessToken: string) {
// WorkOS recommends local JWKS verification for AuthKit access tokens
const { payload } = await workos.userManagement.verifyAccessToken({
accessToken,
clientId: process.env.WORKOS_CLIENT_ID!,
})
return payload
}After — using @synqid/js:
import { SynqClient } from '@synqid/js'
const synq = new SynqClient({
issuer: 'https://api.synq.id',
})
export async function verifyToken(accessToken: string) {
// Local JWKS verification, no network on the hot path after warmup.
const { payload } = await synq.tokens.verify(accessToken, {
audience: process.env.SYNQ_BRAND_SLUG!,
})
return payload
}The audience is the Brand slug, not your client_id. That
means one backend service handles tokens for any number of Apps
under the same Brand (web + mobile + CLI) without an allow-list.
Plain-jose variant (no Synq SDK):
import { createRemoteJWKSet, jwtVerify } from 'jose'
const JWKS = createRemoteJWKSet(
new URL('https://api.synq.id/.well-known/jwks.json'),
)
export async function verifyToken(token: string) {
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://api.synq.id',
audience: process.env.SYNQ_BRAND_SLUG!,
})
return payload
}Gradual rollout
You can run AuthKit and Synq side-by-side behind a feature flag, identical pattern to the Auth0 dual-issuer guard. For SAML connections, the cleanest cutover is per-tenant — move one customer’s IdP to point at Synq’s ACS URL, validate end-to-end, then move the next. That gives you a per-customer rollback path.
Gotchas
- AuthKit’s cookie shape. AuthKit’s session cookie is JWE; Synq’s
session cookie is iron-session (AES-GCM with
SESSION_SECRET). They cannot coexist on the same path. Use distinct cookie names if you run both during cutover (workos.sidandsynq.sid). - Admin Portal. WorkOS’s Admin Portal lets a customer’s admin self-configure their SAML connection. Synq does not ship an Admin Portal today — Org admins configure connections directly in the Synq dashboard. Self-service per-customer Admin Portal is on the roadmap; if it’s a contract dependency, talk to us.
- Vault. WorkOS Vault stored opaque per-user metadata encrypted
at rest. Synq does not provide a Vault equivalent — keep that data
in your own product DB, encrypted there, keyed by Synq
sub. - Events polling. If you scheduled a job that polled
GET /events, port it to a webhook subscription. Synq does not expose a polling events stream today (near-term roadmap). - Audit log retention. Free tier retains ~7 days; paid tiers
longer. If you came from WorkOS expecting unbounded retention, set
up the
auditLog.eventwebhook to your own store. - FGA. WorkOS FGA is fine-grained authz as a service. Synq does
not ship an FGA equivalent. Use Cerbos / Permit.io / OPA from
requireAccess.
What stays the same
- Per-tenant SSO with SAML / OIDC. Same setup ritual.
- SCIM directory sync via the IdP, surfaced as Synq webhooks.
- Hosted login UI (themed per Brand).
- Magic Link without extra code.
- Audit log of all admin and identity events.
- Standard OIDC + JWKS at your backend.
Open questions / drop us a note
WorkOS-shaped enterprise deployments have the highest variance in this guide set. The thing we want to hear from you about: whether the Brand-per-customer topology fits your customer onboarding flow, or whether you’d rather we shipped a self-service Admin Portal sooner. Write to support@synq.id.
See also: Organizations and Brands, BYO credentials, Sessions and tokens, Audit log, Webhooks.