How to Migrate from Auth0 to Keycloak (Step-by-Step Guide)
Last updated: March 2026
Migrating from Auth0 to Keycloak is a project that requires careful planning, but it is very achievable. Teams typically make this move to reduce costs at scale, eliminate vendor lock-in, gain deeper customization control, or meet data residency requirements. Whatever your reason, this guide walks through each step with working code examples and practical advice.
We will cover the full migration: exporting users from Auth0, importing them into Keycloak with password hashes intact, mapping applications to clients, reconfiguring social connections, converting Auth0 Actions to Keycloak authentication flows, and handling custom claims.
Before You Start
Prerequisites
- A running Keycloak instance (version 24+ recommended). You can use:
- Skycloak managed hosting for a production-ready instance
- Our Keycloak Docker Compose Generator for a local development environment
- A self-hosted Kubernetes deployment using our ArgoCD deployment guide
- Auth0 Management API credentials (Machine-to-Machine application with appropriate scopes)
- Python 3.8+ or Node.js 18+ for running migration scripts
- Admin access to both Auth0 and Keycloak
Plan Your Realm Structure
Before importing anything, decide how to organize your Keycloak realm(s). Auth0 tenants map to Keycloak realms. Common patterns:
| Auth0 Structure | Keycloak Equivalent |
|---|---|
| Single tenant | Single realm |
| Tenant per environment | Realm per environment (dev, staging, prod) |
| Organizations | Realm per organization, or use the Organizations feature |
For most migrations, a single realm per Auth0 tenant is the right starting point.
Create the Auth0 Management API Application
You need a Machine-to-Machine (M2M) application in Auth0 with the Management API authorized:
- In Auth0 Dashboard, go to Applications > Create Application
- Choose Machine to Machine Applications
- Select the Auth0 Management API
- Grant these scopes:
read:usersread:user_idp_tokensread:connectionsread:clientsread:client_keysread:rolesread:role_members
Save the Domain, Client ID, and Client Secret.
Step 1: Export Users from Auth0
Auth0 provides two methods for user export: the Management API (for smaller user sets) and the Export Job API (for larger datasets).
Method A: Management API (Under 10,000 Users)
import requests
import json
import time
AUTH0_DOMAIN = "your-tenant.auth0.com"
AUTH0_CLIENT_ID = "YOUR_M2M_CLIENT_ID"
AUTH0_CLIENT_SECRET = "YOUR_M2M_CLIENT_SECRET"
def get_auth0_token():
"""Get a Management API access token."""
response = requests.post(
f"https://{AUTH0_DOMAIN}/oauth/token",
json={
"client_id": AUTH0_CLIENT_ID,
"client_secret": AUTH0_CLIENT_SECRET,
"audience": f"https://{AUTH0_DOMAIN}/api/v2/",
"grant_type": "client_credentials",
},
)
response.raise_for_status()
return response.json()["access_token"]
def export_users(token):
"""Export all users using pagination."""
users = []
page = 0
per_page = 100
while True:
response = requests.get(
f"https://{AUTH0_DOMAIN}/api/v2/users",
headers={"Authorization": f"Bearer {token}"},
params={
"page": page,
"per_page": per_page,
"include_totals": "true",
"fields": "user_id,email,email_verified,name,given_name,"
"family_name,nickname,picture,identities,"
"app_metadata,user_metadata,created_at,"
"last_login,logins_count",
},
)
response.raise_for_status()
data = response.json()
users.extend(data["users"])
print(f"Exported {len(users)} of {data['total']} users")
if len(users) >= data["total"]:
break
page += 1
time.sleep(0.5) # Respect rate limits
return users
token = get_auth0_token()
users = export_users(token)
with open("auth0_users.json", "w") as f:
json.dump(users, f, indent=2)
print(f"Exported {len(users)} users to auth0_users.json")
Method B: Export Job API (Over 10,000 Users)
For larger user bases, use Auth0’s Export Job:
import requests
import time
def create_export_job(token):
"""Create a user export job for large datasets."""
response = requests.post(
f"https://{AUTH0_DOMAIN}/api/v2/jobs/users-exports",
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
json={
"format": "json",
"fields": [
{"name": "user_id"},
{"name": "email"},
{"name": "email_verified"},
{"name": "name"},
{"name": "given_name"},
{"name": "family_name"},
{"name": "identities"},
{"name": "app_metadata"},
{"name": "user_metadata"},
{"name": "created_at"},
],
},
)
response.raise_for_status()
return response.json()
def wait_for_job(token, job_id):
"""Poll until the export job completes."""
while True:
response = requests.get(
f"https://{AUTH0_DOMAIN}/api/v2/jobs/{job_id}",
headers={"Authorization": f"Bearer {token}"},
)
response.raise_for_status()
job = response.json()
if job["status"] == "completed":
return job["location"] # Download URL
elif job["status"] == "failed":
raise Exception(f"Export job failed: {job}")
print(f"Job status: {job['status']}, waiting...")
time.sleep(5)
token = get_auth0_token()
job = create_export_job(token)
download_url = wait_for_job(token, job["id"])
print(f"Download users from: {download_url}")
Handling Password Hashes
Auth0 does not include password hashes in the standard export. For users with Auth0 database connections (username/password), you have two options:
Option A: Request a password hash export from Auth0 Support. Auth0 can provide bcrypt-hashed passwords for database connection users. This is the preferred approach because Keycloak natively supports importing bcrypt-hashed passwords.
Option B: Lazy migration with a custom user federation provider. Instead of importing passwords upfront, you create a custom Keycloak User Storage SPI that validates credentials against Auth0 on first login and then stores the password in Keycloak.
We will cover both approaches. Option A is simpler if Auth0 supports your request.
Step 2: Import Users into Keycloak
Transform Auth0 Users to Keycloak Format
Keycloak’s Admin REST API accepts user creation requests. Here is a script that transforms Auth0 users and imports them:
import requests
import json
KEYCLOAK_URL = "https://keycloak.example.com"
REALM = "your-realm"
KEYCLOAK_ADMIN_USER = "admin"
KEYCLOAK_ADMIN_PASSWORD = "admin-password"
def get_keycloak_token():
"""Get an admin access token from Keycloak."""
response = requests.post(
f"{KEYCLOAK_URL}/realms/master/protocol/openid-connect/token",
data={
"grant_type": "password",
"client_id": "admin-cli",
"username": KEYCLOAK_ADMIN_USER,
"password": KEYCLOAK_ADMIN_PASSWORD,
},
)
response.raise_for_status()
return response.json()["access_token"]
def transform_user(auth0_user):
"""Transform an Auth0 user to Keycloak user representation."""
kc_user = {
"username": auth0_user.get("email", "").lower(),
"email": auth0_user.get("email", ""),
"emailVerified": auth0_user.get("email_verified", False),
"enabled": True,
"firstName": auth0_user.get("given_name", ""),
"lastName": auth0_user.get("family_name", ""),
"attributes": {},
}
# Map Auth0 user_metadata to Keycloak attributes
user_metadata = auth0_user.get("user_metadata", {})
for key, value in user_metadata.items():
kc_user["attributes"][f"auth0_meta_{key}"] = [str(value)]
# Map Auth0 app_metadata to Keycloak attributes
app_metadata = auth0_user.get("app_metadata", {})
for key, value in app_metadata.items():
kc_user["attributes"][f"auth0_app_{key}"] = [str(value)]
# Store the Auth0 user_id for reference
kc_user["attributes"]["auth0_user_id"] = [auth0_user.get("user_id", "")]
return kc_user
def import_user(token, kc_user):
"""Create a user in Keycloak."""
response = requests.post(
f"{KEYCLOAK_URL}/admin/realms/{REALM}/users",
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
json=kc_user,
)
if response.status_code == 201:
return "created"
elif response.status_code == 409:
return "exists"
else:
print(f"Error importing {kc_user['email']}: {response.status_code} {response.text}")
return "error"
# Run the import
token = get_keycloak_token()
with open("auth0_users.json") as f:
auth0_users = json.load(f)
results = {"created": 0, "exists": 0, "error": 0}
for auth0_user in auth0_users:
kc_user = transform_user(auth0_user)
result = import_user(token, kc_user)
results[result] += 1
# Refresh token periodically (Keycloak admin tokens expire)
if (results["created"] + results["exists"] + results["error"]) % 100 == 0:
token = get_keycloak_token()
print(f"Progress: {results}")
print(f"Import complete: {results}")
Importing Password Hashes (bcrypt)
If you received bcrypt password hashes from Auth0, you can import them using Keycloak’s credential representation:
def import_user_with_password(token, kc_user, bcrypt_hash):
"""Create a user with a pre-hashed password."""
# First create the user
response = requests.post(
f"{KEYCLOAK_URL}/admin/realms/{REALM}/users",
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
json=kc_user,
)
if response.status_code != 201:
return "error"
# Get the user ID from the Location header
user_id = response.headers["Location"].split("/")[-1]
# Set the credential with the bcrypt hash
# Keycloak supports bcrypt via the credential representation
credential = {
"type": "password",
"secretData": json.dumps({
"value": bcrypt_hash,
}),
"credentialData": json.dumps({
"hashIterations": 10,
"algorithm": "bcrypt",
}),
}
cred_response = requests.put(
f"{KEYCLOAK_URL}/admin/realms/{REALM}/users/{user_id}/reset-password",
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
json=credential,
)
return "created" if cred_response.status_code < 300 else "error"
For larger imports, consider using Keycloak’s partial import feature, which can process bulk user imports from a JSON file.
Lazy Migration Alternative
If you cannot get password hashes, implement lazy migration. Users authenticate against Auth0 on first login, and their credentials are captured in Keycloak going forward:
// Simplified User Storage SPI for lazy migration
// Full implementation requires the Keycloak SPI framework
public class Auth0MigrationProvider implements UserStorageProvider,
CredentialInputValidator, UserLookupProvider {
@Override
public boolean isValid(RealmModel realm, UserModel user,
CredentialInput input) {
// Validate credentials against Auth0
String email = user.getEmail();
String password = ((UserCredentialModel) input).getValue();
if (validateAgainstAuth0(email, password)) {
// Store the password in Keycloak for future logins
UserCredentialModel cred = UserCredentialModel
.password(password, false);
session.userCredentialManager()
.updateCredential(realm, user, cred);
// Remove the federation link so future logins use Keycloak directly
user.setFederationLink(null);
return true;
}
return false;
}
}
This approach means users migrate transparently on their next login. No password reset required. For more on custom user attributes during migration, see our guide on using custom user attributes in Keycloak OIDC tokens.
Step 3: Map Applications to Clients
Each Auth0 Application maps to a Keycloak Client. Here is how the concepts translate:
| Auth0 | Keycloak | Notes |
|---|---|---|
| Regular Web Application | Confidential client | Has a client secret |
| Single Page Application | Public client | Uses PKCE, no secret |
| Machine to Machine | Service account client | Client credentials grant |
| Native Application | Public client | Uses PKCE |
Export Auth0 Applications
def export_applications(token):
"""Export all Auth0 applications."""
response = requests.get(
f"https://{AUTH0_DOMAIN}/api/v2/clients",
headers={"Authorization": f"Bearer {token}"},
params={"include_totals": "true"},
)
response.raise_for_status()
return response.json()["clients"]
apps = export_applications(token)
with open("auth0_applications.json", "w") as f:
json.dump(apps, f, indent=2)
Create Keycloak Clients
def create_keycloak_client(token, auth0_app):
"""Create a Keycloak client from an Auth0 application."""
app_type = auth0_app.get("app_type", "regular_web")
client = {
"clientId": auth0_app["client_id"],
"name": auth0_app.get("name", ""),
"description": f"Migrated from Auth0: {auth0_app.get('name', '')}",
"enabled": True,
"protocol": "openid-connect",
"redirectUris": auth0_app.get("callbacks", []),
"webOrigins": auth0_app.get("allowed_origins", []),
"attributes": {
"post.logout.redirect.uris": "##".join(
auth0_app.get("allowed_logout_urls", [])
),
},
}
# Set client type based on Auth0 app type
if app_type == "spa":
client["publicClient"] = True
client["standardFlowEnabled"] = True
client["directAccessGrantsEnabled"] = False
elif app_type == "non_interactive":
# Machine-to-Machine
client["publicClient"] = False
client["serviceAccountsEnabled"] = True
client["standardFlowEnabled"] = False
client["directAccessGrantsEnabled"] = False
else:
# Regular web app
client["publicClient"] = False
client["standardFlowEnabled"] = True
client["directAccessGrantsEnabled"] = False
client["secret"] = auth0_app.get("client_secret", "")
response = requests.post(
f"{KEYCLOAK_URL}/admin/realms/{REALM}/clients",
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
json=client,
)
if response.status_code == 201:
print(f"Created client: {client['clientId']}")
else:
print(f"Error creating {client['clientId']}: {response.text}")
After creating clients, verify the redirect URIs match your application configuration. Redirect URI mismatches are the most common post-migration issue. See our redirect URI troubleshooting guide if you run into problems.
Step 4: Migrate Social Connections
Auth0 social connections map to Keycloak Identity Providers. You will need to create new OAuth credentials for each provider pointing to Keycloak’s callback URL.
Mapping Social Connections
| Auth0 Connection | Keycloak Identity Provider | Callback URL |
|---|---|---|
| google-oauth2 | {keycloak-url}/realms/{realm}/broker/google/endpoint |
|
| github | GitHub | {keycloak-url}/realms/{realm}/broker/github/endpoint |
{keycloak-url}/realms/{realm}/broker/facebook/endpoint |
||
| apple | Apple | {keycloak-url}/realms/{realm}/broker/apple/endpoint |
| windowslive | Microsoft | {keycloak-url}/realms/{realm}/broker/microsoft/endpoint |
Configure Google as an Example
-
Go to the Google Cloud Console
-
Create a new OAuth 2.0 Client ID (or update the existing one)
-
Add the Keycloak callback URL as an authorized redirect URI:
https://keycloak.example.com/realms/your-realm/broker/google/endpoint -
In Keycloak Admin Console:
- Navigate to Identity Providers > Add provider > Google
- Enter the Client ID and Client Secret from Google
- Configure scopes:
openid email profile - Set First Login Flow to your preferred handling (auto-link, prompt, etc.)
For a complete walkthrough of identity provider configuration, see our identity providers feature page and our guide on using GitHub social login with Keycloak.
Linking Migrated Users to Social Identities
Users who signed up through social connections in Auth0 need their social identities linked in Keycloak. Auth0’s user export includes an identities array with the provider and user ID:
def link_social_identity(token, keycloak_user_id, auth0_identity):
"""Link a social identity to an existing Keycloak user."""
provider = auth0_identity["provider"]
provider_user_id = auth0_identity["user_id"]
# Map Auth0 provider names to Keycloak IdP aliases
provider_map = {
"google-oauth2": "google",
"github": "github",
"facebook": "facebook",
"apple": "apple",
"windowslive": "microsoft",
}
kc_provider = provider_map.get(provider)
if not kc_provider:
print(f"Unknown provider: {provider}")
return
# Create federated identity link
response = requests.post(
f"{KEYCLOAK_URL}/admin/realms/{REALM}/users/{keycloak_user_id}"
f"/federated-identity/{kc_provider}",
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
json={
"identityProvider": kc_provider,
"userId": provider_user_id,
"userName": auth0_identity.get("profileData", {}).get("email", ""),
},
)
if response.status_code < 300:
print(f"Linked {kc_provider} identity for user {keycloak_user_id}")
else:
print(f"Error: {response.status_code} {response.text}")
Step 5: Convert Auth0 Actions to Keycloak Flows
Auth0 Actions (and the older Rules/Hooks) are JavaScript functions that execute at specific points in the authentication pipeline. Keycloak handles the same functionality through:
- Authentication Flows: visual flow editor for login, registration, and post-login steps
- Protocol Mappers: for customizing token claims
- Event Listeners: for post-authentication actions (webhooks, logging)
- SPI Extensions: for deep customization (Java-based)
Common Migrations
Auth0: Add custom claims to tokens
// Auth0 Action - Login / Post Login
exports.onExecutePostLogin = async (event, api) => {
const namespace = 'https://myapp.com';
api.idToken.setCustomClaim(`${namespace}/roles`, event.authorization.roles);
api.accessToken.setCustomClaim(`${namespace}/org_id`, event.user.app_metadata.org_id);
};
Keycloak equivalent: Protocol Mapper
In Keycloak, you do not need code for this. Create a Protocol Mapper on the client:
- Navigate to Clients > your client > Client scopes > Dedicated scope
- Add a mapper:
- Type: User Attribute
- User Attribute:
org_id - Token Claim Name:
https://myapp.com/org_id - Add to ID token: Yes
- Add to access token: Yes
For role mappings, Keycloak includes roles in tokens by default. You can customize the claim name and structure using the “Realm Role” or “Client Role” mapper types.
You can verify your token claims with our JWT Token Analyzer.
Auth0: Block users based on metadata
// Auth0 Action - deny login for blocked users
exports.onExecutePostLogin = async (event, api) => {
if (event.user.app_metadata.blocked) {
api.access.deny('Your account has been suspended.');
}
};
Keycloak equivalent: Custom Authenticator or Conditional Flow
In Keycloak, you can use a Conditional Authentication Flow:
- Navigate to Authentication > Flows
- Create or copy the Browser flow
- Add a Condition – User Attribute sub-flow
- Configure it to check for a
blockedattribute - If the condition matches, add a Deny Access authenticator
For more complex conditions, you may need a custom Authenticator SPI. See Keycloak’s authentication SPI documentation.
Auth0: Enrich user profile on first login
// Auth0 Action - enrich profile from external API
exports.onExecutePostLogin = async (event, api) => {
if (event.stats.logins_count === 1) {
const enrichment = await fetchUserData(event.user.email);
api.user.setAppMetadata('company', enrichment.company);
}
};
Keycloak equivalent: Event Listener or First Broker Login Flow
For first-login enrichment, create an Event Listener SPI that listens for LOGIN events and checks if it is the user’s first login:
public class UserEnrichmentListener implements EventListenerProvider {
@Override
public void onEvent(Event event) {
if (event.getType() == EventType.LOGIN) {
UserModel user = session.users()
.getUserById(realm, event.getUserId());
// Check if this is the first login
String loginCount = user
.getFirstAttribute("login_count");
if (loginCount == null) {
// Enrich user from external API
enrichUser(user);
user.setSingleAttribute("login_count", "1");
}
}
}
}
Step 6: Migrate Roles and Permissions
Auth0 RBAC maps cleanly to Keycloak’s role system:
def export_auth0_roles(token):
"""Export roles from Auth0."""
response = requests.get(
f"https://{AUTH0_DOMAIN}/api/v2/roles",
headers={"Authorization": f"Bearer {token}"},
)
response.raise_for_status()
return response.json()
def create_keycloak_role(token, role_name, description=""):
"""Create a realm role in Keycloak."""
response = requests.post(
f"{KEYCLOAK_URL}/admin/realms/{REALM}/roles",
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
json={
"name": role_name,
"description": description,
},
)
return response.status_code < 300
def assign_role_to_user(token, user_id, role_name):
"""Assign a realm role to a user."""
# Get the role representation
role_response = requests.get(
f"{KEYCLOAK_URL}/admin/realms/{REALM}/roles/{role_name}",
headers={"Authorization": f"Bearer {token}"},
)
if role_response.status_code != 200:
return False
role = role_response.json()
# Assign the role
response = requests.post(
f"{KEYCLOAK_URL}/admin/realms/{REALM}/users/{user_id}/role-mappings/realm",
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
json=[role],
)
return response.status_code < 300
For fine-grained authorization beyond RBAC, see our guide on fine-grained authorization in Keycloak and the RBAC feature page.
Step 7: Update Application Configurations
With users, clients, and identity providers migrated, update your applications to point to Keycloak:
OIDC Discovery
Replace Auth0’s discovery URL with Keycloak’s:
# Auth0
https://your-tenant.auth0.com/.well-known/openid-configuration
# Keycloak
https://keycloak.example.com/realms/your-realm/.well-known/openid-configuration
Most OIDC libraries use auto-discovery, so changing the issuer URL is often the only change needed.
Environment Variable Changes
# Before (Auth0)
AUTH_ISSUER=https://your-tenant.auth0.com
AUTH_CLIENT_ID=auth0_client_id
AUTH_CLIENT_SECRET=auth0_client_secret
AUTH_AUDIENCE=https://api.example.com
# After (Keycloak)
AUTH_ISSUER=https://keycloak.example.com/realms/your-realm
AUTH_CLIENT_ID=your-keycloak-client-id
AUTH_CLIENT_SECRET=your-keycloak-client-secret
# Audience is optional in Keycloak; use resource server configuration instead
Token Validation
If your APIs validate JWTs, update the issuer and JWKS URI:
// Node.js example using jose
import { jwtVerify, createRemoteJWKSet } from 'jose';
// Keycloak JWKS endpoint
const JWKS = createRemoteJWKSet(
new URL(
'https://keycloak.example.com/realms/your-realm/protocol/openid-connect/certs'
)
);
async function validateToken(token) {
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://keycloak.example.com/realms/your-realm',
});
return payload;
}
For more on token validation patterns, see our post on Keycloak token validation for APIs.
Step 8: Testing and Cutover
Pre-Cutover Testing Checklist
- [ ] All users imported with correct attributes
- [ ] Password authentication works (test with a few users)
- [ ] Social login works for each configured provider
- [ ] Token claims match your application’s expectations (use the JWT Token Analyzer)
- [ ] Redirect URIs are correctly configured for all clients
- [ ] Refresh token flows work correctly
- [ ] Logout flows work (including single sign-on logout)
- [ ] MFA flows work if enabled
- [ ] SCIM provisioning works if configured (test with the SCIM Tester)
- [ ] Audit logs are capturing events
- [ ] CORS is configured correctly for SPAs (CORS guide)
Cutover Strategy
Option A: Big bang cutover
- Put Auth0 in read-only mode (remove write permissions)
- Run a final user export/import
- Update all application configurations to point to Keycloak
- Deploy all applications simultaneously
- Monitor for errors
Option B: Gradual migration (recommended)
- Set up Keycloak as an identity broker for Auth0 using OIDC identity brokering
- Point new applications to Keycloak
- Migrate existing applications one at a time
- Users authenticate through Keycloak, which delegates to Auth0 during transition
- As users log in, their accounts are created in Keycloak via lazy migration
- Once all users have migrated, remove the Auth0 broker
Option B is safer because it allows rollback per application and does not require downtime.
Common Gotchas
1. Auth0 user_id format. Auth0 user IDs look like auth0|abc123 or google-oauth2|12345. Store these as a custom attribute in Keycloak so you can cross-reference during the transition.
2. Passwordless users. Users who only use social login or passwordless methods in Auth0 will not have passwords to migrate. Ensure they can still log in through the same social providers or set up passwordless in Keycloak.
3. Custom domains. If you use a custom domain in Auth0 (e.g., login.example.com), you can configure the same domain for Keycloak. With Skycloak managed hosting, custom domains are supported out of the box.
4. Rate limiting. Auth0’s Management API has rate limits. The export scripts above include basic rate limit handling, but adjust the delays if you hit 429 responses.
5. Token claim differences. Auth0 and Keycloak structure tokens slightly differently. Audit your application’s token parsing logic and update as needed. Use the JWT Token Analyzer to compare tokens side by side.
6. SAML applications. If you have SAML service providers connected to Auth0, you will need to reconfigure them to trust Keycloak as the IdP. See our guides on configuring Keycloak as a SAML SP and SAML attribute mapping. Use the SAML Decoder to inspect SAML assertions during debugging.
Wrapping Up
Migrating from Auth0 to Keycloak is a structured process. The critical path is: export users with password hashes, import them into Keycloak, recreate your clients and social connections, convert custom logic, and update your applications.
The gradual migration approach using identity brokering is the safest path. It lets you migrate applications one at a time and gives users a seamless experience during the transition.
For teams that want the full power of Keycloak without managing infrastructure, Skycloak’s managed hosting handles upgrades, backups, monitoring, and SLA guarantees so you can focus on the migration itself. See our pricing to get started, or use our ROI Calculator to estimate the cost savings of switching from Auth0.
Ready to simplify your authentication?
Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.