Home AuthenticationHow to Migrate to Ory from Auth0

How to Migrate to Ory from Auth0

By sk
Published: Updated: 78 views 10 mins read

Auth0 has been one of the most popular identity platforms for years. It's polished, well documented, and can get most applications from zero to a working login flow in very little time.

But as applications grow, many teams start looking for alternatives that offer lower long-term costs, self-hosting options, open-source components, and greater control over user data.

That's where Ory comes in.

Ory provides a modern identity and access management stack built around open standards such as OAuth 2.0, OpenID Connect, and WebAuthn. You can use it through Ory Network (managed SaaS) or deploy it yourself on your own infrastructure.

In this guide, we'll walk through a step-by-step migration from Auth0 to Ory, covering user export, SDK replacement, and the gotchas nobody tells you about.

Migrate from Auth0 to Ory: Overview

Migrating from Auth0 to Ory takes 5 steps:

  1. Export user accounts from Auth0 using the Management API
  2. Request password hashes via Auth0 support ticket (paid plans only)
  3. Set up an Ory project (Ory Network or self-hosted)
  4. Import user identities using the Ory admin API
  5. Replace the Auth0 SDK in your application with the Ory SDK

Auth0 vs Ory: Comparison

Before you migrate, know what you're trading.

FeatureAuth0Ory
Setup speed✅ Very fast⚠️ Steeper curve
Managed cloud✅ Yes✅ Yes (Ory Network)
Self-hostable❌ No✅ Yes
Open source❌ No✅ Yes
UI ownership⚠️ Customizable but limited✅ Fully headless
OAuth2/OIDC
Social login
MFA
Pricing at scale❌ Expensive✅ Significantly cheaper
Vendor lock-in❌ High✅ Low
Data ownership❌ Auth0 holds your data✅ You own it

When to stay on Auth0: You're a small team, speed matters more than cost, you don't need self-hosting, and you're happy paying for convenience.

When to switch to Ory: You're scaling past ~10k MAU, you want full control of user data, you want to self-host (compliance, air-gap, GDPR), or your Auth0 bill has become a concern.

Want the full feature-by-feature breakdown? Ory maintains an official comparison covering pricing tiers, protocol support, deployment options, compliance features, and SDK availability:

Read that first if you're still deciding whether migration makes sense for your situation. This guide picks up from the point where you've made the call.

Cost

Ory Network pricing:

  • Free: Development/testing
  • Growth: Priced per active daily authenticated user (aDAU), typically 5–10x cheaper than Auth0 at scale
  • Self-hosted (Enterprise License): Flat fee, unlimited users

Auth0 pricing changes frequently. Please verify current rates at auth0.com/pricing before making any decisions.

Migration Strategy

You have two destination options:

Option A: Ory Network (managed cloud): Best if you want to stay managed but escape Auth0's pricing and lock-in. Minimal infra work. Your code changes are mostly just SDK swaps.

Option B: Self-hosted Ory (Kratos + Hydra): Best if you need full data sovereignty, air-gapped environments, or compliance requirements. More setup, but you own everything.

Step 1: Export Your Auth0 Users

Auth0 lets you export users via the Management API. You'll need a Management API token.

# Get a Management API token (replace YOUR_DOMAIN and credentials)
curl --request POST \
--url https://YOUR_DOMAIN.auth0.com/oauth/token \
--header 'content-type: application/json' \
--data '{
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"audience": "https://YOUR_DOMAIN.auth0.com/api/v2/",
"grant_type": "client_credentials"
}'

Then export users:

curl --request POST \
--url 'https://YOUR_DOMAIN.auth0.com/api/v2/jobs/users-exports' \
--header 'authorization: Bearer YOUR_MGMT_TOKEN' \
--header 'content-type: application/json' \
--data '{
"connection_id": "YOUR_CONNECTION_ID",
"format": "json",
"fields": [
{"name": "user_id"},
{"name": "email"},
{"name": "name"},
{"name": "email_verified"},
{"name": "created_at"}
]
}'

Poll the job status until complete:

curl --request GET \
--url 'https://YOUR_DOMAIN.auth0.com/api/v2/jobs/YOUR_JOB_ID' \
--header 'authorization: Bearer YOUR_MGMT_TOKEN'

The Password Hash Situation (Read This Carefully)

Auth0 does not include password hashes in standard bulk exports. However, there are two paths forward:

Path A - Request hashes (paid Auth0 plans only):

Paid Auth0 customers can request a password hash export by opening a support ticket: Auth0 Dashboard → Get Support → Tickets → View All → Open Ticket → "I have a question regarding my Auth0 account" → "I would like to obtain an export of my tenant password hashes."

When Auth0 processes the request, you'll receive a compressed JSON file with user IDs, password hashes, and related information. Import these using Ory's credentials.password.config.hashed_password field (see Step 3).

Path B - Graceful migration (all plans):

Import user accounts without passwords, then use Ory's Password Migration Hook to migrate users lazily on their next login. They authenticate against Auth0 once more, and Ory takes over from that point.

Free tier Auth0 users cannot access hash exports and must use Path B.

Step 2: Set Up Ory

Option A: Ory Network

# Install the Ory CLI — Linux/macOS via the official install script:
curl https://raw.githubusercontent.com/ory/meta/master/install.sh | sh -s ory
sudo mv ./ory /usr/local/bin/

# macOS users can alternatively use Homebrew:
# brew install ory/tap/cli

# Login or create an account
ory auth

# Create a new project
ory create project --name "my-app"

# Get your workspace and project IDs (needed for config commands)
ory list workspaces
ory list projects --workspace <workspace-id>

Your SDK URL will look like: https://yourproject.projects.oryapis.com

Option B: Self-Hosted

Version note: Always verify the latest stable image tag at changelog.ory.com before deploying. Using latest is fine for evaluation; pin to a specific verified tag in production.

For production, use PostgreSQL rather than SQLite:

# docker-compose.yml (production excerpt)
services:
postgres:
image: postgres:15
environment:
POSTGRES_USER: kratos
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} # use .env, never hardcode
POSTGRES_DB: kratos
volumes:
- pgdata:/var/lib/postgresql/data

kratos:
image: oryd/kratos:latest # replace with the current pinned tag from changelog.ory.com
environment:
- DSN=postgres://kratos:${POSTGRES_PASSWORD}@postgres:5432/kratos?sslmode=disable

volumes:
pgdata:

Step 3: Import Users into Ory

Ory Kratos's admin API supports both single-identity creation (POST /admin/identities) and true bulk import (PATCH /admin/identities). For large migrations, use the batch endpoint. It accepts multiple identities per request, handles partial failures gracefully, and returns per-identity results correlated by patch_id.

Schema ID note: New Ory Network projects use "preset://email" as the default schema_id. Self-hosted projects may use a different value. Check your default_schema_id in the Ory Identity configuration. The preset://email schema only allows email as a trait (additionalProperties: false), so name data is stored in metadata_public below. If your schema includes name fields, you can move them into traits directly.

// import-users.js
const axios = require('axios');
const fs = require('fs');
const { randomUUID } = require('crypto'); // Node.js 14.17+

const KRATOS_ADMIN = process.env.KRATOS_ADMIN_URL || 'http://localhost:4434';
const ORY_API_KEY = process.env.ORY_API_KEY || '';

// Ory Network default: "preset://email"
// Self-hosted: match your configured default_schema_id (e.g. "default")
const SCHEMA_ID = process.env.ORY_SCHEMA_ID || 'preset://email';

const auth0Users = JSON.parse(fs.readFileSync('./auth0-export.json', 'utf8'));

function buildIdentityPatch(auth0User) {
return {
patch_id: randomUUID(), // correlates each entry in the response back to this input
create: {
schema_id: SCHEMA_ID,
state: 'active',
traits: {
email: auth0User.email,
// Note: preset://email has additionalProperties: false — only email is allowed.
// If your schema includes name fields, add them here:
// name: { first: auth0User.name?.split(' ')[0], last: auth0User.name?.split(' ').slice(1).join(' ') }
},
verifiable_addresses: [
{
value: auth0User.email,
verified: auth0User.email_verified,
via: 'email',
status: auth0User.email_verified ? 'completed' : 'pending',
},
],
// Preserve Auth0 ID and display name for reference during transition
metadata_public: {
auth0_id: auth0User.user_id,
name: auth0User.name || '',
migrated_at: new Date().toISOString(),
},
},
};
}

async function importBatch(patches) {
const headers = { 'Content-Type': 'application/json' };
if (ORY_API_KEY) headers['Authorization'] = `Bearer ${ORY_API_KEY}`;

const { data } = await axios.patch(
`${KRATOS_ADMIN}/admin/identities`,
{ identities: patches },
{ headers }
);
return data.identities; // [{ action, patch_id, identity (UUID string) | error }]
}

async function importAll() {
const batchSize = 50; // reduce to 20–25 if you hit 504 timeouts
const results = [];

for (let i = 0; i < auth0Users.length; i += batchSize) {
const batch = auth0Users.slice(i, i + batchSize);
const patches = batch.map(buildIdentityPatch);

// Map patch_id → email so we can correlate results back to the input user
const patchIdToEmail = new Map(patches.map((p, idx) => [p.patch_id, batch[idx].email]));

try {
const batchResults = await importBatch(patches);
for (const r of batchResults) {
const email = patchIdToEmail.get(r.patch_id) || r.patch_id;
if (r.action === 'create') {
console.log(`✅ Imported: ${email} → Ory ID: ${r.identity}`);
results.push({ success: true, email, oryId: r.identity });
} else {
const reason = r.error?.reason || r.error?.message || 'unknown error';
console.error(`❌ Failed: ${email} — ${reason}`);
results.push({ success: false, email, error: r.error });
}
}
} catch (err) {
console.error(`❌ Batch ${i}–${i + batchSize} failed:`, err.response?.data || err.message);
}

await new Promise(r => setTimeout(r, 200)); // brief pause between batches
}

const succeeded = results.filter(r => r.success).length;
const failed = results.filter(r => !r.success).length;
console.log(`\nImport complete: ${succeeded} succeeded, ${failed} failed`);
fs.writeFileSync('./import-results.json', JSON.stringify(results, null, 2));
}

importAll();

Run it:

KRATOS_ADMIN_URL=https://yourproject.projects.oryapis.com \
ORY_API_KEY=your-ory-api-key \
node import-users.js

If you have password hashes from Auth0: add a credentials block inside each create object:

"credentials": {
"password": {
"config": {
"hashed_password": "$2a$10$..."
}
}
}

Ory supports BCrypt, Argon2, MD5, SHA, PBKDF2, SCrypt, and more — no re-hashing required for most Auth0 exports.

Step 4: Swap the SDK in Your Application

Auth0 SDK (before):

const { auth, requiresAuth } = require('express-openid-connect');

app.use(auth({
authRequired: false,
auth0Logout: true,
secret: process.env.AUTH0_SECRET,
baseURL: 'http://localhost:3000',
clientID: process.env.AUTH0_CLIENT_ID,
issuerBaseURL: `https://${process.env.AUTH0_DOMAIN}`,
}));

app.get('/profile', requiresAuth(), (req, res) => {
res.json(req.oidc.user);
});

Ory SDK (after):

const { FrontendApi, Configuration } = require('@ory/client-fetch');

const ory = new FrontendApi(new Configuration({
basePath: process.env.ORY_SDK_URL,
credentials: 'include',
}));

const requireAuth = async (req, res, next) => {
try {
const session = await ory.toSession({ cookie: req.headers.cookie });
req.user = session.identity;
next();
} catch {
res.redirect('/login');
}
};

app.get('/profile', requireAuth, (req, res) => {
res.json(req.user.traits);
});

Environment variable changes:

# Remove:
AUTH0_SECRET=...
AUTH0_CLIENT_ID=...
AUTH0_DOMAIN=...

# Add:
ORY_SDK_URL=https://yourproject.projects.oryapis.com

Step 5: Migrate Social Connections

Option A: Ory Network

For Ory Network, social providers are configured through the Ory Console UI or the Ory CLI, not by editing config files directly.

Via the Console (easiest for common providers like Google and GitHub):

  1. Go to your project at console.ory.sh
  2. Navigate to Authentication → Social Sign-In (OIDC)
  3. Enable the Enable OpenID Connect toggle and set your base redirect URI
  4. Click Add new OpenID Connect provider, select your provider, and enter the Client ID and Secret from the provider's developer console

Via the Ory CLI (scriptable, good for automation):

# Pull the current identity config for your project
ory get identity-config \
--project <project-id> \
--workspace <workspace-id> \
--format yaml > identity-config.yaml

Edit identity-config.yaml to add your providers under methods.oidc:

methods:
oidc:
config:
base_redirect_uri: https://your-app.example.com # your app's base URL, NOT the Ory project URL
providers:
- id: google # used in the OAuth callback URL — do NOT change after first login
provider: google
label: Google
client_id: YOUR_GOOGLE_CLIENT_ID
client_secret: YOUR_GOOGLE_CLIENT_SECRET
scope:
- email
- profile
mapper_url: base64://YOUR_BASE64_ENCODED_JSONNET
enabled: true

- id: github # used in the OAuth callback URL — do NOT change after first login
provider: github
label: GitHub
client_id: YOUR_GITHUB_CLIENT_ID
client_secret: YOUR_GITHUB_CLIENT_SECRET
scope:
- user:email
mapper_url: base64://YOUR_BASE64_ENCODED_JSONNET
enabled: true

Then apply the changes:

ory update identity-config \
--project <project-id> \
--workspace <workspace-id> \
--file identity-config.yaml

Option B: Self-Hosted

For self-hosted Kratos, add providers directly to kratos/config.yml under selfservice.methods.oidc:

selfservice:
methods:
oidc:
enabled: true
config:
providers:
- id: google
provider: google
client_id: YOUR_GOOGLE_CLIENT_ID
client_secret: YOUR_GOOGLE_CLIENT_SECRET
scope:
- email
- profile
mapper_url: base64://YOUR_JSONNET_MAPPER

- id: github
provider: github
client_id: YOUR_GITHUB_CLIENT_ID
client_secret: YOUR_GITHUB_CLIENT_SECRET
scope:
- user:email
mapper_url: base64://YOUR_JSONNET_MAPPER

Social users from Auth0 will use "Sign in with Google/GitHub" as before. Ory creates new identities on first login. Map by email to link to existing accounts.

The 5 Gotchas Nobody Tells You About

1. The password hash situation: See Step 1 above. Plan for graceful migration or request hashes via support ticket.

2. Token format differences: Auth0 uses JWTs with custom claims. Ory Hydra issues standards-compliant OAuth2 tokens. If your API validates tokens directly, update your validation logic.

3. Auth0 Rules / Actions have no direct equivalent: Auth0's post-login Actions (custom JavaScript) map to Ory Actions. Document every action before migrating and build equivalent Ory Actions handlers.

4. The sub claim will change: Every user's unique ID will be a new UUID in Ory. If you store Auth0 user IDs in your database, you need a mapping table during transition. The metadata_public.auth0_id field set during import is your bridge.

5. CORS is stricter in Ory: Ory requires explicit allowed origin configuration. Verify serve.public.cors.allowed_origins in your Kratos config matches your app's domain exactly.

Staged Migration Plan (Zero-Downtime Rollout)

  1. Import users into Ory without touching your live app
  2. Run both in parallel — new registrations go to Ory, existing users still use Auth0
  3. Lazily migrate existing users via password reset or migration hook on next login
  4. Switch your app to Ory SDK once >90% of active users are migrated
  5. Keep Auth0 tenant active for 30–60 days post-cutover as a safety net
  6. Decommission Auth0 once you're confident

Final Verdict

Migrating from Auth0 to Ory isn't trivial - budget 1–2 sprints for a production app. But the payoff is real: lower cost at scale, full data ownership, and the ability to self-host if you ever need it.

Resources:

You May Also Like

Leave a Comment

* By using this form you agree with the storage and handling of your data by this website.

This site uses Akismet to reduce spam. Learn how your comment data is processed.

This website uses cookies to improve your experience. By using this site, we will assume that you're OK with it. Accept Read More