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
pnpm add @synqid/nextjsandpnpm remove @auth0/nextjs-auth0.- Replace
lib/auth0.tswithlib/synq.ts(onecreateSynqcall). - Replace
app/api/auth/[auth0]/route.tswithapp/api/auth/[...synq]/route.ts. - Replace
<UserProvider>with<SynqProvider>. Seed it from the server. - Replace
getSession()withverifySession()(or its aliasauth()). - Map Auth0 Rules / Actions to Synq webhooks for side effects,
and to product-side
requireAccesspredicates for authz. - 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.
| Auth0 | Synq | Notes |
|---|---|---|
Tenant (e.g. acme.us.auth0.com) | Brand | A tenant is a trust surface — logo, login page, custom domain. That maps to a Brand. |
Auth0 organization (org_…) | Org | An Auth0 organization (paid feature) is a developer-facing entity with members. So is a Synq Org. |
| Application (Regular Web App / SPA / Native / M2M) | App | OIDC client. Synq’s type field is web / spa / native / m2m / agent. |
API (audience) | First-party API — no Synq concept needed | Synq access tokens already carry an aud claim (your Brand slug). Your backend verifies via JWKS. |
| Custom Domain | Brand custom domain | Configure in the Synq dashboard under Brand → Custom Domains. |
| Rules / Hooks (deprecated) | Webhooks + custom claims | See below. |
| Actions (Login, Post-Login, Pre-User-Registration, etc.) | Webhooks + custom claims | See below. |
| Connections (Database / Social / Enterprise) | Brand Connections | Configured per-brand. BYO Google / Apple / Microsoft credentials. See BYO credentials. |
| Roles + Permissions (RBAC) | Product-side enum + requireAccess | Synq does not model permissions. See “Roles & permissions” below. |
| Universal Login page | Brand sign-in surface | Themed by Brand settings (logo, primary color, copy). |
Auth0.js / @auth0/nextjs-auth0 | @synqid/js + @synqid/nextjs | One per surface. |
Management API (mgmt.auth0.com) | Synq REST API | See API reference. |
| Logs (Tenant Logs + Streams) | Synq audit log + webhooks | See 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/nextjsRequires 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:
| Auth0 | Synq |
|---|---|
AUTH0_DOMAIN | (none — issuer defaults to https://api.synq.id) |
AUTH0_CLIENT_ID | SYNQ_CLIENT_ID |
AUTH0_CLIENT_SECRET | SYNQ_CLIENT_SECRET |
AUTH0_SECRET | SESSION_SECRET |
APP_BASE_URL | APP_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:
| Auth0 | Synq |
|---|---|
/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/sessionyourself 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.jsonThe 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... ← AppleSynq’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 Action | Synq equivalent |
|---|---|
| Post-Login: send welcome email | Webhook on member.added or consent.granted |
| Post-Login: enrich token with role | Custom claims in Brand dashboard |
| Pre-User-Registration: block by email domain | Brand → Connections → allow-list |
| Post-Login: redirect by user state | requireAccess predicate + redirectTo |
| Post-Change-Password | (Synq currently does not emit a password event — near-term roadmap) |
| Send Phone Message: SMS MFA | Brand MFA settings (configure per-Brand) |
| Machine to Machine: customize scopes | App 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_idin 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’saudis 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.eventwebhook 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.