Skip to Content

Migrating from Auth0

You have an Auth0 tenant in production. You want Synq. This guide is the line-by-line walk-through — what to install, what to rip out, how to map the concepts, and how to run both providers side-by-side during the cutover so nobody gets signed out at 3am.

We assume a Next.js app using @auth0/nextjs-auth0. The shapes are similar enough that React Native and Express variants will mostly follow along.

TL;DR — the seven moves

  1. pnpm add @synqid/nextjs and pnpm remove @auth0/nextjs-auth0.
  2. Replace lib/auth0.ts with lib/synq.ts (one createSynq call).
  3. Replace app/api/auth/[auth0]/route.ts with app/api/auth/[...synq]/route.ts.
  4. Replace <UserProvider> with <SynqProvider>. Seed it from the server.
  5. Replace getSession() with verifySession() (or its alias auth()).
  6. Map Auth0 Rules / Actions to Synq webhooks for side effects, and to product-side requireAccess predicates for authz.
  7. Switch your backend’s JWKS URL. Run a dual-issuer guard during cutover.

The rest of this page expands each step.

Concept mapping

The mental model lines up surprisingly cleanly. The one wrinkle: Auth0 collapses tenant + branding + client into a flat list of “Applications”. Synq splits them into Organizations and Brands.

Auth0SynqNotes
Tenant (e.g. acme.us.auth0.com)BrandA tenant is a trust surface — logo, login page, custom domain. That maps to a Brand.
Auth0 organization (org_…)OrgAn Auth0 organization (paid feature) is a developer-facing entity with members. So is a Synq Org.
Application (Regular Web App / SPA / Native / M2M)AppOIDC client. Synq’s type field is web / spa / native / m2m / agent.
API (audience)First-party API — no Synq concept neededSynq access tokens already carry an aud claim (your Brand slug). Your backend verifies via JWKS.
Custom DomainBrand custom domainConfigure in the Synq dashboard under Brand → Custom Domains.
Rules / Hooks (deprecated)Webhooks + custom claimsSee below.
Actions (Login, Post-Login, Pre-User-Registration, etc.)Webhooks + custom claimsSee below.
Connections (Database / Social / Enterprise)Brand ConnectionsConfigured per-brand. BYO Google / Apple / Microsoft credentials. See BYO credentials.
Roles + Permissions (RBAC)Product-side enum + requireAccessSynq does not model permissions. See “Roles & permissions” below.
Universal Login pageBrand sign-in surfaceThemed by Brand settings (logo, primary color, copy).
Auth0.js / @auth0/nextjs-auth0@synqid/js + @synqid/nextjsOne per surface.
Management API (mgmt.auth0.com)Synq REST APISee API reference.
Logs (Tenant Logs + Streams)Synq audit log + webhooksSee Audit log.

The biggest cognitive shift is sub claim format, covered in “Preserving user identity” below. Read that section before you plan a cutover.

Step 1 — install

pnpm remove @auth0/nextjs-auth0 pnpm add @synqid/nextjs

Requires next >= 16 and react >= 19. If you are still on Next 14, upgrade Next first; do not try to upgrade Next and auth at the same time.

Step 2 — replace lib/auth0.ts with lib/synq.ts

Before — lib/auth0.ts:

import { Auth0Client } from '@auth0/nextjs-auth0/server' export const auth0 = new Auth0Client({ domain: process.env.AUTH0_DOMAIN!, clientId: process.env.AUTH0_CLIENT_ID!, clientSecret: process.env.AUTH0_CLIENT_SECRET!, appBaseUrl: process.env.APP_BASE_URL!, secret: process.env.AUTH0_SECRET!, authorizationParameters: { scope: 'openid profile email read:campaigns', audience: 'https://api.acme.com', }, })

After — 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!, // openssl rand -base64 32 scopes: ['openid', 'profile', 'email', 'campaigns:read'], } export const { verifySession, getUser, requireGuest, requireAccess, auth } = createSynq(synqConfig)

Env var mapping:

Auth0Synq
AUTH0_DOMAIN(none — issuer defaults to https://api.synq.id)
AUTH0_CLIENT_IDSYNQ_CLIENT_ID
AUTH0_CLIENT_SECRETSYNQ_CLIENT_SECRET
AUTH0_SECRETSESSION_SECRET
APP_BASE_URLAPP_URL
AUTH0_AUDIENCE(none — aud is the Brand slug, set in dashboard)

Scopes change. Auth0 uses colon-separated scope names like read:campaigns. Synq does the same shape; rename if you want, but nothing forces you to. Whatever your Brand declares as its scope vocabulary is what your Apps can request.

Step 3 — the route handler

Before — app/api/auth/[auth0]/route.ts:

import { handleAuth } from '@auth0/nextjs-auth0' export const GET = handleAuth()

After — app/api/auth/[...synq]/route.ts:

import { handlers } from '@synqid/nextjs/handlers' import { synqConfig } from '@/lib/synq' export const { GET, POST } = handlers(synqConfig)

URL mapping:

Auth0Synq
/api/auth/login/api/auth/login
/api/auth/logout/api/auth/logout
/api/auth/callback/api/auth/callback
/api/auth/me/api/auth/session
/api/auth/access-token(no client equivalent — use verifySession() server-side)

The login + logout URLs are identical. If your codebase has <a href="/api/auth/login"> links scattered around, they keep working unchanged. Synq deliberately mirrors the Auth0 path shape here.

Step 4 — the provider

Before — app/layout.tsx:

import { UserProvider } from '@auth0/nextjs-auth0/client' export default function RootLayout({ children }) { return ( <html lang="en"> <body> <UserProvider>{children}</UserProvider> </body> </html> ) }

After:

import { getUser } from '@/lib/synq' import { SynqProvider } from '@synqid/nextjs/client' export default async function RootLayout({ children }) { const user = await getUser() return ( <html lang="en"> <body> <SynqProvider user={user}>{children}</SynqProvider> </body> </html> ) }

Two differences worth calling out:

  • The layout is now an async server component. The user is fetched on the server and passed in as a prop. No flash of unauthenticated UI on first paint.
  • <UserProvider> polls /api/auth/me. <SynqProvider> does not fetch on its own — it trusts the seeded user from the server. If you want client-side refresh, call /api/auth/session yourself and feed it back in.

Step 5 — the proxy (new)

Auth0 SDK uses a Next.js middleware to inject the session helper. Synq’s proxy is more explicit — it’s the optimistic edge gate that redirects to login on a missing cookie.

// src/proxy.ts import { synqProxy } from '@synqid/nextjs/proxy' export const proxy = synqProxy({ publicPaths: ['/about', '/pricing'], }) export const config = { matcher: ['/((?!api/auth|_next/static|_next/image|favicon.ico).*)'], }

If you had withMiddlewareAuthRequired() from the Auth0 SDK, this replaces it.

Step 6 — replace getSession() / withApiAuthRequired()

The four reads you probably have:

Before:

import { getSession, withApiAuthRequired } from '@auth0/nextjs-auth0' // Server component const session = await getSession() const { user } = session ?? {} // Route handler export const GET = withApiAuthRequired(async (req) => { const session = await getSession(req, /* ... */) // ... })

After:

import { verifySession, getUser } from '@/lib/synq' // Server component — strict (redirects to login if no session) const { user } = await verifySession() // Server component — soft (renders signed-out UI if no session) const user = await getUser() // Route handler — strict export async function GET() { const { user, accessToken } = await verifySession() // ... }

verifySession() does what withApiAuthRequired did: redirects if no session. getUser() is the non-throwing variant.

Step 7 — switching JWKS at your backend

Your backend probably verifies Auth0 access tokens locally against https://acme.us.auth0.com/.well-known/jwks.json. Switch it to Synq’s JWKS, which is published at:

https://api.synq.id/.well-known/jwks.json

The OIDC discovery document is at https://api.synq.id/.well-known/openid-configuration.

If you use jose directly:

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: 'acme-marketplace', // your Brand slug, NOT client_id }) return payload }

audience is the Brand slug, not the client_id. Re-read Sessions and tokens if that’s surprising — it lets the same backend accept tokens for multiple Apps under the same Brand without listing each client_id.

Preserving user identity through cutover

This is the section that bites people. Plan it before you migrate anyone.

Auth0’s sub claim is provider-prefixed:

auth0|6502cf... ← database connection google-oauth2|117... ← Google apple|001234... ← Apple

Synq’s sub is an opaque internal user id with no provider prefix. On the user’s first sign-in via Synq, they will get a new sub, which means any record keyed by Auth0 sub won’t find them.

Three strategies, pick one:

Strategy A — re-key by email at cutover (simple, lossy)

If your users table has email indexed and unique, you can re-key on first Synq sign-in: look up by email, replace the foreign key, done. Works when email is stable. Breaks for users with multiple emails or no email.

// In the first authenticated request post-cutover: const { user } = await verifySession() const localUser = await db.users.findOne({ synqSub: user.sub }) ?? await db.users.findOneAndUpdate( { email: user.email }, { $set: { synqSub: user.sub } }, )

Strategy B — migrate sub via a one-time import (clean)

Export your Auth0 users (/api/v2/jobs/users-exports). For each user, create the corresponding Synq user via the Admin API and stash the mapping. On cutover, look up by the old Auth0 sub once, write the new Synq sub to the same row.

Synq exposes user import via the Directory API endpoints; ping support if you need help shaping a bulk import.

Strategy C — dual-issuer guard (zero-downtime)

Run both Auth0 and Synq token verification at your backend during a transition window. New sign-ins go to Synq; existing Auth0 tokens keep working until they naturally expire (≤ 24h for Auth0 default access tokens).

import { createRemoteJWKSet, jwtVerify } from 'jose' const AUTH0_JWKS = createRemoteJWKSet( new URL(`https://${process.env.AUTH0_DOMAIN}/.well-known/jwks.json`), ) const SYNQ_JWKS = createRemoteJWKSet( new URL('https://api.synq.id/.well-known/jwks.json'), ) export async function verifyAny(token: string) { // Cheap header inspection to pick the right JWKS const iss = JSON.parse( Buffer.from(token.split('.')[1], 'base64url').toString(), ).iss as string if (iss === 'https://api.synq.id') { const { payload } = await jwtVerify(token, SYNQ_JWKS, { issuer: 'https://api.synq.id', audience: process.env.SYNQ_BRAND_SLUG!, }) return { provider: 'synq' as const, sub: payload.sub! } } // Anything else → try Auth0 const { payload } = await jwtVerify(token, AUTH0_JWKS, { issuer: `https://${process.env.AUTH0_DOMAIN}/`, audience: process.env.AUTH0_AUDIENCE!, }) return { provider: 'auth0' as const, sub: payload.sub! } }

Pair this with a database column users.synqSub (nullable) populated on every Synq verify. When synqSub coverage hits 100% of active users, delete the Auth0 branch.

We recommend Strategy C for anything with real user volume. Strategy A is fine for low-volume tools; Strategy B is the right call when you also need to migrate org membership, roles, or audit history at the same time.

Rules, Actions, Hooks → Synq webhooks + custom claims

Auth0 Actions run inside the auth flow and let you mutate the issued token or block the login. Synq splits these jobs:

  • Side effects — “send a welcome email”, “provision a Stripe customer”, “create a Slack invite” — become webhooks. Fire-and-forget, retried with backoff, never block sign-in.
  • Issued-token shape — adding a claim to the access token / ID token — become custom claims configured per-Brand in the Synq dashboard. Pull from a lookup the Brand defines.
  • Authorization decisions — “block if user is on the deny-list”, “require MFA for admins” — become product-side checks inside requireAccess(predicate). Synq does not opine.
Auth0 ActionSynq equivalent
Post-Login: send welcome emailWebhook on member.added or consent.granted
Post-Login: enrich token with roleCustom claims in Brand dashboard
Pre-User-Registration: block by email domainBrand → Connections → allow-list
Post-Login: redirect by user staterequireAccess predicate + redirectTo
Post-Change-Password(Synq currently does not emit a password event — near-term roadmap)
Send Phone Message: SMS MFABrand MFA settings (configure per-Brand)
Machine to Machine: customize scopesApp scope allow-list in dashboard

The cultural difference: Auth0 wants you to embed your logic inside the auth flow. Synq wants your auth flow to be inert and your logic to live in your product. That’s the source of every “where do I put this in Synq?” question — the answer is almost always outside the auth flow.

Roles & permissions

Auth0’s RBAC adds permissions to the access token. Synq does not. That sounds like a regression until you realize how often you ended up parsing permissions server-side anyway because the Auth0 rules engine couldn’t express your real rules.

The Synq pattern:

import { requireAccess } from '@/lib/synq' const { user } = await requireAccess(async () => { const perms = await yourProductSdk.permissions.me() return perms.includes('CampaignEdit') })

Move the permission set into your own product database (or a service like Cerbos, Permit.io, OPA). Read it inside the predicate. The auth token stays small; permission changes don’t require a re-login; the data model is yours.

For super-simple cases — a hard-coded list of admin emails — just gate inline:

const ADMINS = new Set(['kelvin@acme.com', 'john@acme.com']) const { user } = await requireAccess(({ user }) => ADMINS.has(user.email))

Auth0 organizations → Synq orgs

If you used Auth0’s “Organizations” feature, the mental shape is near-identical. The differences:

  • Auth0 includes org_id in the access token when the user signs in through an organization. Synq’s org context comes from your product (an org slug in the URL, a header, a session attribute). The access token’s aud is the Brand, not an org.
  • Auth0 has a per-org login page. Synq’s Brand-themed login screen handles the equivalent. If you need per-tenant theming on top of the Brand, do it client-side.
  • Auth0 invitations come with a per-org URL. Synq’s invitation flow uses an /orgs/<slug>/invite/<token> URL handled by your product.

For most Auth0 Orgs users, the migration is: keep org membership in your database, key by Synq sub, look up on every request.

Gotchas

  • Rate limits. Auth0 free tier limits Management API to ~1 req/s. Synq’s per-Org admin rate limits are different — see API reference. Anything that hammered the Auth0 Management API will work fine against Synq, but re-check before you fan out from a Lambda.
  • Log retention. Auth0’s tenant logs default to 30 days. Synq’s audit log retention is tier-bound (see Audit log). If you relied on Auth0’s logs for compliance, set up the auditLog.event webhook and ship to your own store.
  • Custom domains. Auth0 lets you serve the login at auth.acme.com. Synq’s per-Brand custom domain is configured under Brand → Custom Domains. Same outcome; setup is a 5-minute CNAME + cert request.
  • MFA. Auth0 supports OTP / WebAuthn / Push. Synq supports OTP and WebAuthn today; push factor is on the near-term roadmap. If you rely on Auth0 Guardian push, talk to us before cutover.
  • Database connections (username + password). Auth0 stores passwords. Synq does not — users authenticate via social providers or magic link. If you have a large database connection, you will need to either send a password-reset campaign before cutover or contact us about an import flow that emits a magic-link to each user.

Complete file diff — typical login route

app/api/auth/login/route.ts (a custom login route you might have that wraps Auth0’s):

// BEFORE import { handleAuth, handleLogin } from '@auth0/nextjs-auth0' export const GET = handleAuth({ login: handleLogin({ authorizationParams: { audience: 'https://api.acme.com', scope: 'openid profile email read:campaigns', }, returnTo: '/dashboard', }), })
// AFTER import { handlers } from '@synqid/nextjs/handlers' import { synqConfig } from '@/lib/synq' // Move the file to app/api/auth/[...synq]/route.ts and use the // catch-all. There is no per-action customization needed — scopes // live in synqConfig, returnTo is a query param the proxy already // passes through. export const { GET, POST } = handlers(synqConfig)

The before-and-after collapses ~25 lines into 2. That’s the win.

Open questions / drop us a note

Migration from Auth0 in production is the case we care about most. If anything in this guide is wrong for your topology — sub-domain multi-tenancy, Auth0 fine-grained authz, custom database connections with a password hash you need to keep — write to support@synq.id before you start. We will help you shape the rollout.

See also: Sessions and tokens, Webhooks, Organizations and brands.

Last updated on