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.
Table of Contents
Migrate from Auth0 to Ory: Overview
Migrating from Auth0 to Ory takes 5 steps:
- Export user accounts from Auth0 using the Management API
- Request password hashes via Auth0 support ticket (paid plans only)
- Set up an Ory project (Ory Network or self-hosted)
- Import user identities using the Ory admin API
- Replace the Auth0 SDK in your application with the Ory SDK
Auth0 vs Ory: Comparison
Before you migrate, know what you're trading.
| Feature | Auth0 | Ory |
|---|---|---|
| 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
latestis 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 defaultschema_id. Self-hosted projects may use a different value. Check yourdefault_schema_idin the Ory Identity configuration. Thepreset://emailschema only allowsadditionalProperties: false), so name data is stored inmetadata_publicbelow. If your schema includes name fields, you can move them intotraitsdirectly.
// 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):
- Go to your project at console.ory.sh
- Navigate to Authentication → Social Sign-In (OIDC)
- Enable the Enable OpenID Connect toggle and set your base redirect URI
- 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:
- 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:
- 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)
- Import users into Ory without touching your live app
- Run both in parallel — new registrations go to Ory, existing users still use Auth0
- Lazily migrate existing users via password reset or migration hook on next login
- Switch your app to Ory SDK once >90% of active users are migrated
- Keep Auth0 tenant active for 30–60 days post-cutover as a safety net
- 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:
