Auth0's pricing changes pushed a lot of teams to reconsider. Monthly active user costs that were negligible at 10,000 users become significant at 100,000, and enterprise features like Organizations and Attack Protection are locked behind plans that can easily reach $2,000–$5,000 per month. This playbook covers the full migration path: user export, schema mapping, OIDC client recreation, SDK swap, and cutover procedure. The goal is zero downtime and zero forced password resets.
Phase 1: Export users from Auth0
Auth0 does not expose password hashes through its Management API — this is intentional and correct from a security perspective. What you can export is everything else: user IDs, email addresses, verification status, metadata, and connection information. For passwords, you have two options: trigger a bulk password reset after migration, or use Auth0's user import/export extension which can export bcrypt hashes if your tenant is configured to allow it.
Start by creating a Management API token with read:users scope. Auth0 rate-limits the Management API to 2 requests per second on most plans, so bulk exports need careful pagination.
#!/usr/bin/env python3
import requests, json, time
DOMAIN = "your-tenant.us.auth0.com"
TOKEN = "your_management_api_token"
def export_users(page_size=100):
headers = {"Authorization": f"Bearer {TOKEN}"}
users, page = [], 0
while True:
resp = requests.get(
f"https://{DOMAIN}/api/v2/users",
headers=headers,
params={
"per_page": page_size,
"page": page,
"include_totals": "true",
"fields": "user_id,email,email_verified,name,given_name,"
"family_name,picture,created_at,updated_at,"
"last_login,app_metadata,user_metadata,identities"
}
)
resp.raise_for_status()
data = resp.json()
batch = data.get("users", data if isinstance(data, list) else [])
users.extend(batch)
if len(batch) < page_size:
break
page += 1
time.sleep(0.5) # stay under rate limit
return users
users = export_users()
with open("auth0_export.json", "w") as f:
json.dump(users, f, indent=2)
print(f"Exported {len(users)} users")
For tenants with over 500,000 users, use the Auth0 User Import/Export extension. It generates a background job that streams results to an S3 bucket, avoiding pagination limits entirely.
Phase 2: Schema mapping
Auth0 user objects have a specific shape that does not map one-to-one to most internal schemas. The critical field translations are:
user_id(e.g.,auth0|64a3f...) maps to your internalidor anauth0_subcolumn. Strip the connection prefix or keep it as an external reference. Do not generate new UUIDs or you break existing tokens.email_verifiedis a direct boolean, but verify the field exists on every record. Users created via social providers often have it settrueby Auth0 even when the original IdP did not explicitly return that claim.app_metadatacontains tenant-controlled fields (roles, plan, flags). Map these to first-class columns, not a JSON blob, wherever possible.user_metadatacontains user-controlled fields (display preferences, timezone). Safe to store as JSONB.identitiesis an array of linked provider connections. Preserve this to maintain social login continuity post-migration.
-- Target schema
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
auth0_sub TEXT UNIQUE, -- preserve for JWT sub claim continuity
email TEXT UNIQUE NOT NULL,
email_verified BOOLEAN NOT NULL DEFAULT false,
name TEXT,
given_name TEXT,
family_name TEXT,
picture_url TEXT,
app_metadata JSONB DEFAULT '{}',
user_metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_login_at TIMESTAMPTZ
);
CREATE TABLE user_identities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
provider TEXT NOT NULL, -- 'google-oauth2', 'github', etc.
provider_id TEXT NOT NULL,
UNIQUE(provider, provider_id)
);
auth0_sub as the JWT sub claim value in your new system, existing tokens issued by Auth0 that haven't expired yet can be validated by your new service — as long as you accept the old issuer temporarily during the cutover window.Phase 3: Recreate OIDC clients
In Auth0 terminology an "application" is an OIDC client. Each application has a client ID, client secret, grant types, and a set of allowed redirect URIs. You need to recreate these exactly, particularly the redirect URIs, because any mismatch will cause authorization errors for your users.
Pull your existing application list from the Management API:
GET https://{domain}/api/v2/clients
Authorization: Bearer {token}
# Fields you need from each client:
# name, client_id, app_type, grant_types,
# callbacks (allowed redirect URIs),
# allowed_logout_urls,
# jwt_configuration.alg,
# token_endpoint_auth_method
When creating equivalent clients in your new system, the client IDs will change. This is unavoidable. Your strategy for handling this during the transition window is to run both systems simultaneously and route traffic based on which client ID appears in the authorization request.
Grant type mapping
- Auth0 "Regular Web Application" → Authorization Code with PKCE
- Auth0 "Single Page Application" → Authorization Code with PKCE (implicit grant is legacy and should not be recreated)
- Auth0 "Machine to Machine" → Client Credentials grant
- Auth0 "Native Application" → Authorization Code with PKCE + device constraints
Phase 4: Update redirect URIs
Every place in your application that constructs an authorization URL needs to be updated. The most common locations:
- Frontend SDK initialization (the
domainandclientIdparams) - Backend callback handlers (the route that receives the authorization code)
- Logout redirect URLs
- Mobile app deep link schemes registered for OAuth callbacks
- CI/CD environment variables that set auth domain
Use a feature flag to switch the authorization endpoint at runtime. This lets you test the new system with a percentage of traffic before full cutover.
// Before (auth0-js)
import { Auth0Client } from '@auth0/auth0-spa-js';
const auth = new Auth0Client({
domain: 'your-tenant.us.auth0.com',
clientId: 'abc123',
authorizationParams: {
redirect_uri: window.location.origin + '/callback'
}
});
// After (bastionary-js)
import { BastionaryClient } from '@bastionary/auth-spa';
const auth = new BastionaryClient({
issuer: 'https://auth.yourdomain.com',
clientId: 'your_new_client_id',
redirectUri: window.location.origin + '/callback'
});
// The public API surface is intentionally similar
await auth.loginWithRedirect();
const user = await auth.getUser();
const token = await auth.getTokenSilently();
Phase 5: SDK swap
The auth0-js and @auth0/auth0-spa-js SDKs are purpose-built for Auth0's specific endpoints. Replacing them is not just a configuration change — the SDKs call Auth0-specific endpoints like /userinfo with Auth0's exact response format, and the token refresh mechanism uses Auth0's rotation semantics.
Any OIDC-compliant SDK will work as a replacement. Bastionary ships a drop-in replacement that mirrors the Auth0 SPA SDK surface intentionally. For server-side usage, openid-client (Node.js) is a solid choice that works with any compliant provider.
// Node.js server-side with openid-client
import { Issuer, generators } from 'openid-client';
const issuer = await Issuer.discover('https://auth.yourdomain.com');
const client = new issuer.Client({
client_id: 'your_client_id',
client_secret: 'your_client_secret',
redirect_uris: ['https://yourapp.com/callback'],
response_types: ['code'],
});
// Generate PKCE pair
const code_verifier = generators.codeVerifier();
const code_challenge = generators.codeChallenge(code_verifier);
// Authorization URL
const authUrl = client.authorizationUrl({
scope: 'openid email profile',
code_challenge,
code_challenge_method: 'S256',
});
// Token exchange at callback
const params = client.callbackParams(req);
const tokenSet = await client.callback(
'https://yourapp.com/callback',
params,
{ code_verifier }
);
Phase 6: Pre-cutover testing checklist
Before flipping the switch, run through every auth flow manually in a staging environment with production-clone data:
- Email/password login with a known migrated user
- Social login via each configured provider (Google, GitHub, etc.)
- Password reset flow end-to-end
- Email verification flow for a newly created user
- Token refresh after the access token expires (typically 15 min — accelerate expiry in staging to test)
- Logout and verify session cookie is cleared
- Machine-to-machine token via client credentials grant
- MFA enrollment and login
- Account linking (if you support merging social + email accounts)
- Verify JWT claims match what your backend middleware expects (
sub,iss,aud,exp)
iss, aud, or claim structure will cause your backend token validation middleware to reject tokens silently.Phase 7: Cutover day procedure
The cutover itself should take under 30 minutes if the preceding phases are solid. Here is the sequence:
- Enable maintenance banner on your app (optional but reduces confusion)
- Freeze user creation in Auth0 by disabling sign-up (Dashboard > Applications > your app > Connections)
- Run a final incremental export from Auth0 to catch any users created since your initial export
- Import the delta into your new system
- Flip the feature flag that routes authorization requests to the new issuer
- Test one full login flow in production before opening traffic
- Monitor error rates on
/authorize,/token, and/userinfoendpoints for 30 minutes - Remove maintenance banner
- Keep Auth0 tenant live (not deleted) for 30 days to handle any stragglers with cached sessions
Handling sessions in flight
Users who have an active session at cutover time have a refresh token issued by Auth0. When that refresh token is used, your new system won't recognize it. You have two choices: accept a one-time re-login for these users (display a clear message explaining why), or run a temporary bridge service that intercepts refresh requests, validates them against Auth0, and issues new tokens from your system. The bridge approach adds complexity but gives a seamless experience for long-lived sessions like mobile apps.
For most web applications, accepting a re-login for active sessions is the right tradeoff. Users are accustomed to being logged out occasionally. Display a message like "We've updated our authentication system — please log in again" and the drop-off rate will be negligible.
Cost expectations
A self-hosted or Bastionary-hosted auth system eliminates per-MAU pricing entirely. The operational overhead is real: you own the availability, the security patching, and the on-call rotation for auth failures. For teams under 5 engineers, a hosted alternative to Auth0 (like Bastionary) removes that burden while still cutting the per-MAU cost to a predictable flat fee. For larger engineering organizations, self-hosting is the right call once you have dedicated platform infrastructure.