How to Migrate from Clerk to Keycloak

Guilliano Molaire Guilliano Molaire Updated May 24, 2026 12 min read

Last updated: March 2026

Clerk has gained popularity for its drop-in authentication components, but teams outgrow it. Common reasons include pricing that scales steeply with monthly active users, limited customization of authentication flows, vendor lock-in concerns, and the need for features like fine-grained authorization or SAML federation that Clerk does not offer. Keycloak gives you full control over your identity infrastructure while supporting every major authentication protocol.

This guide walks through the complete migration: exporting users from Clerk, importing them into Keycloak with password hashes preserved, replacing Clerk components with Keycloak-powered authentication, migrating webhooks, and handling the transition with minimal user disruption.

Migration Overview

Migration Step Complexity Downtime Required
User data export Low None
Password hash migration Medium None
Client/application mapping Low None
Component replacement Medium-High Brief (deploy window)
Webhook migration Medium None
Social connection reconfiguration Low None
Session migration Low Users re-authenticate once

Total estimated timeline: 2-4 weeks for a typical application.

Prerequisites

Before starting the migration, you will need:

  • A running Keycloak instance (version 24+). Options:
  • Clerk Backend API key (from Clerk Dashboard > API Keys)
  • Node.js 18+ for running migration scripts
  • Admin access to both Clerk and Keycloak

Set up your environment variables:

export CLERK_SECRET_KEY="sk_live_xxxxxxxxxxxx"
export KEYCLOAK_URL="https://auth.example.com"
export KEYCLOAK_REALM="my-app"
export KEYCLOAK_ADMIN_USER="admin"
export KEYCLOAK_ADMIN_PASSWORD="your-admin-password"

Step 1: Export Users from Clerk

Clerk does not provide a bulk export feature in the dashboard. You need to use the Clerk Backend API to paginate through all users.

User Export Script

// export-clerk-users.js
const fs = require('fs');

const CLERK_SECRET_KEY = process.env.CLERK_SECRET_KEY;
const CLERK_API_URL = 'https://api.clerk.com/v1';

async function fetchAllUsers() {
  const users = [];
  let offset = 0;
  const limit = 100;

  while (true) {
    const response = await fetch(
      `${CLERK_API_URL}/users?limit=${limit}&offset=${offset}&order_by=-created_at`,
      {
        headers: {
          'Authorization': `Bearer ${CLERK_SECRET_KEY}`,
          'Content-Type': 'application/json'
        }
      }
    );

    if (!response.ok) {
      throw new Error(`Clerk API error: ${response.status} ${response.statusText}`);
    }

    const batch = await response.json();
    if (batch.length === 0) break;

    users.push(...batch);
    offset += limit;

    console.log(`Fetched ${users.length} users...`);

    // Respect rate limits
    await new Promise(resolve => setTimeout(resolve, 100));
  }

  return users;
}

async function exportUsers() {
  console.log('Starting Clerk user export...');
  const users = await fetchAllUsers();

  // Transform to a portable format
  const exportedUsers = users.map(user => ({
    clerkId: user.id,
    email: user.email_addresses?.find(e => e.id === user.primary_email_address_id)?.email_address,
    emailVerified: user.email_addresses?.find(
      e => e.id === user.primary_email_address_id
    )?.verification?.status === 'verified',
    firstName: user.first_name,
    lastName: user.last_name,
    username: user.username,
    phone: user.phone_numbers?.find(p => p.id === user.primary_phone_number_id)?.phone_number,
    imageUrl: user.image_url,
    createdAt: user.created_at,
    lastSignInAt: user.last_sign_in_at,
    passwordEnabled: user.password_enabled,
    twoFactorEnabled: user.two_factor_enabled,
    externalAccounts: user.external_accounts?.map(ea => ({
      provider: ea.provider,
      providerId: ea.provider_user_id,
      email: ea.email_address
    })),
    publicMetadata: user.public_metadata,
    privateMetadata: user.private_metadata,
    unsafeMetadata: user.unsafe_metadata
  }));

  fs.writeFileSync(
    'clerk-users-export.json',
    JSON.stringify(exportedUsers, null, 2)
  );

  console.log(`Exported ${exportedUsers.length} users to clerk-users-export.json`);
}

exportUsers().catch(console.error);

Run the export:

node export-clerk-users.js

Step 2: Handle Password Hashes

This is the trickiest part of any authentication migration. Clerk uses bcrypt for password hashing, and Keycloak supports bcrypt password verification through its credential storage system.

Exporting Password Hashes

Clerk does not expose password hashes through the public API. You have two options:

Option A: Contact Clerk support to request a password hash export. They may provide this for customers migrating away, as the hashes belong to your users.

Option B: Trigger password resets for all users after migration. This is the simpler approach and is what most teams end up doing.

If you obtain the bcrypt hashes, you can import them into Keycloak using the Admin REST API’s credential representation:

// import-user-with-password.js
async function importUserWithBcryptHash(keycloakToken, realmName, user, bcryptHash) {
  const keycloakUser = {
    username: user.email,
    email: user.email,
    emailVerified: user.emailVerified,
    firstName: user.firstName,
    lastName: user.lastName,
    enabled: true,
    credentials: [{
      type: 'password',
      credentialData: JSON.stringify({
        hashIterations: 10,
        algorithm: 'bcrypt'
      }),
      secretData: JSON.stringify({
        value: bcryptHash
      })
    }],
    attributes: {
      clerk_id: [user.clerkId],
      migrated_from: ['clerk'],
      migration_date: [new Date().toISOString()]
    }
  };

  const response = await fetch(
    `${process.env.KEYCLOAK_URL}/admin/realms/${realmName}/users`,
    {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${keycloakToken}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(keycloakUser)
    }
  );

  if (response.status === 409) {
    console.log(`User ${user.email} already exists, skipping`);
    return;
  }

  if (!response.ok) {
    const error = await response.text();
    throw new Error(`Failed to create user ${user.email}: ${error}`);
  }

  console.log(`Created user: ${user.email}`);
}

Option B: Password Reset Flow

If you cannot get the password hashes, import users without passwords and trigger a password reset email:

async function importUserAndResetPassword(keycloakToken, realmName, user) {
  // Create user without credentials
  const keycloakUser = {
    username: user.email,
    email: user.email,
    emailVerified: user.emailVerified,
    firstName: user.firstName,
    lastName: user.lastName,
    enabled: true,
    requiredActions: ['UPDATE_PASSWORD'],
    attributes: {
      clerk_id: [user.clerkId],
      migrated_from: ['clerk']
    }
  };

  const createResponse = await fetch(
    `${process.env.KEYCLOAK_URL}/admin/realms/${realmName}/users`,
    {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${keycloakToken}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(keycloakUser)
    }
  );

  if (!createResponse.ok) {
    console.log(`Skipping ${user.email}: ${createResponse.status}`);
    return;
  }

  // Get the created user's ID from the Location header
  const userId = createResponse.headers.get('Location')?.split('/').pop();

  if (userId) {
    // Send password reset email
    await fetch(
      `${process.env.KEYCLOAK_URL}/admin/realms/${realmName}/users/${userId}/execute-actions-email`,
      {
        method: 'PUT',
        headers: {
          'Authorization': `Bearer ${keycloakToken}`,
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(['UPDATE_PASSWORD'])
      }
    );
    console.log(`Created user and sent reset email: ${user.email}`);
  }
}

Step 3: Bulk Import Script

Here is the complete import script that ties together user creation with error handling and rate limiting:

// import-to-keycloak.js
const fs = require('fs');

async function getAdminToken() {
  const response = await fetch(
    `${process.env.KEYCLOAK_URL}/realms/master/protocol/openid-connect/token`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'password',
        client_id: 'admin-cli',
        username: process.env.KEYCLOAK_ADMIN_USER,
        password: process.env.KEYCLOAK_ADMIN_PASSWORD
      })
    }
  );
  const data = await response.json();
  return data.access_token;
}

async function importAllUsers() {
  const users = JSON.parse(fs.readFileSync('clerk-users-export.json', 'utf-8'));
  const token = await getAdminToken();
  const realm = process.env.KEYCLOAK_REALM;

  const results = { created: 0, skipped: 0, failed: 0 };

  for (const user of users) {
    try {
      if (!user.email) {
        console.log(`Skipping user ${user.clerkId}: no email`);
        results.skipped++;
        continue;
      }

      const keycloakUser = {
        username: user.email.toLowerCase(),
        email: user.email.toLowerCase(),
        emailVerified: user.emailVerified,
        firstName: user.firstName || '',
        lastName: user.lastName || '',
        enabled: true,
        requiredActions: user.passwordEnabled ? [] : ['UPDATE_PASSWORD'],
        attributes: {
          clerk_id: [user.clerkId],
          migrated_from: ['clerk'],
          migration_date: [new Date().toISOString()]
        }
      };

      // Preserve custom metadata as user attributes
      if (user.publicMetadata && Object.keys(user.publicMetadata).length > 0) {
        keycloakUser.attributes.clerk_public_metadata = [
          JSON.stringify(user.publicMetadata)
        ];
      }

      const response = await fetch(
        `${process.env.KEYCLOAK_URL}/admin/realms/${realm}/users`,
        {
          method: 'POST',
          headers: {
            'Authorization': `Bearer ${token}`,
            'Content-Type': 'application/json'
          },
          body: JSON.stringify(keycloakUser)
        }
      );

      if (response.status === 201) {
        results.created++;
        console.log(`[${results.created}] Created: ${user.email}`);
      } else if (response.status === 409) {
        results.skipped++;
      } else {
        results.failed++;
        const error = await response.text();
        console.error(`Failed: ${user.email} - ${error}`);
      }

      // Rate limiting
      await new Promise(resolve => setTimeout(resolve, 50));
    } catch (err) {
      results.failed++;
      console.error(`Error processing ${user.email}: ${err.message}`);
    }
  }

  console.log('nImport complete:', results);
}

importAllUsers().catch(console.error);

Step 4: Map Clerk Applications to Keycloak Clients

Clerk applications map to Keycloak clients. Here is how common Clerk configurations translate:

Clerk Setting Keycloak Equivalent
Frontend API OIDC Client (public, Authorization Code + PKCE)
Backend API OIDC Client (confidential, Client Credentials)
JWT Templates Client Scopes with Protocol Mappers
Session duration Realm Session Settings
Allowed origins Web Origins on the client
Redirect URLs Valid Redirect URIs

Creating the Frontend Client

# Using Keycloak Admin CLI (kcadm.sh)
kcadm.sh create clients -r my-app 
  -s clientId=frontend-app 
  -s publicClient=true 
  -s 'redirectUris=["https://app.example.com/*"]' 
  -s 'webOrigins=["https://app.example.com"]' 
  -s standardFlowEnabled=true 
  -s directAccessGrantsEnabled=false

You can also use the Keycloak Config Generator to build client configurations interactively.

Mapping JWT Claims

Clerk’s sessionClaims and JWT templates are replaced by Keycloak protocol mappers. If you had custom claims in Clerk, create equivalent mappers:

# Add a custom claim mapper for user role
kcadm.sh create clients/$CLIENT_ID/protocol-mappers/models -r my-app 
  -s name=user-role 
  -s protocol=openid-connect 
  -s protocolMapper=oidc-usermodel-attribute-mapper 
  -s 'config."claim.name"=role' 
  -s 'config."user.attribute"=role' 
  -s 'config."id.token.claim"=true' 
  -s 'config."access.token.claim"=true' 
  -s 'config."jsonType.label"=String'

After creating your clients, use the JWT Token Analyzer to compare the tokens issued by Keycloak with what Clerk was producing. This helps catch any missing claims before you update your backend validation logic.

Step 5: Replace Clerk Components

Clerk’s primary value proposition is its pre-built React components. Here is how to replace each one.

Replacing <SignIn /> and <SignUp />

Clerk provides <SignIn /> and <SignUp /> components that render embedded forms. With Keycloak, authentication happens via redirect to the Keycloak login page, which you can fully customize with Keycloak themes and branding.

Before (Clerk):

import { SignIn } from '@clerk/clerk-react';

function LoginPage() {
  return <SignIn />;
}

After (Keycloak with keycloak-js):

import Keycloak from 'keycloak-js';
import { useEffect, useState } from 'react';

const keycloak = new Keycloak({
  url: 'https://auth.example.com',
  realm: 'my-app',
  clientId: 'frontend-app'
});

function App() {
  const [authenticated, setAuthenticated] = useState(false);
  const [initialized, setInitialized] = useState(false);

  useEffect(() => {
    keycloak.init({
      onLoad: 'check-sso',
      pkceMethod: 'S256',
      checkLoginIframe: false
    }).then(auth => {
      setAuthenticated(auth);
      setInitialized(true);
    });
  }, []);

  const login = () => keycloak.login();
  const signup = () => keycloak.register();
  const logout = () => keycloak.logout({ redirectUri: window.location.origin });

  if (!initialized) return <div>Loading...</div>;

  if (!authenticated) {
    return (
      <div>
        <button onClick={login}>Sign In</button>
        <button onClick={signup}>Sign Up</button>
      </div>
    );
  }

  return <AuthenticatedApp keycloak={keycloak} />;
}

Replacing <UserButton />

Clerk’s <UserButton /> shows a user avatar with a dropdown for profile management and sign out. Build an equivalent:

function UserMenu({ keycloak }) {
  const [open, setOpen] = useState(false);
  const token = keycloak.tokenParsed;

  return (
    <div className="user-menu">
      <button onClick={() => setOpen(!open)} className="user-avatar">
        {token?.name?.charAt(0) || token?.email?.charAt(0) || '?'}
      </button>
      {open && (
        <div className="user-dropdown">
          <p>{token?.name}</p>
          <p>{token?.email}</p>
          <button onClick={() => keycloak.accountManagement()}>
            Manage Account
          </button>
          <button onClick={() => keycloak.logout({
            redirectUri: window.location.origin
          })}>
            Sign Out
          </button>
        </div>
      )}
    </div>
  );
}

Replacing useAuth() Hook

// hooks/useKeycloakAuth.js
import { useState, useEffect, createContext, useContext } from 'react';

const AuthContext = createContext(null);

export function KeycloakAuthProvider({ keycloak, children }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    if (keycloak.authenticated) {
      setUser({
        id: keycloak.tokenParsed?.sub,
        email: keycloak.tokenParsed?.email,
        firstName: keycloak.tokenParsed?.given_name,
        lastName: keycloak.tokenParsed?.family_name,
        roles: keycloak.tokenParsed?.realm_access?.roles || []
      });
    }

    // Auto-refresh token
    const interval = setInterval(() => {
      keycloak.updateToken(30).catch(() => keycloak.login());
    }, 30000);

    return () => clearInterval(interval);
  }, [keycloak]);

  return (
    <AuthContext.Provider value={{
      user,
      isAuthenticated: keycloak.authenticated,
      isLoaded: true,
      getToken: () => keycloak.token,
      login: () => keycloak.login(),
      logout: () => keycloak.logout({ redirectUri: window.location.origin })
    }}>
      {children}
    </AuthContext.Provider>
  );
}

export const useAuth = () => useContext(AuthContext);

For a complete guide to connecting React apps with Keycloak, see our React OIDC integration guide.

Step 6: Migrate Webhooks

Clerk sends webhooks for events like user.created, user.updated, session.created, and organization.created. Keycloak has a built-in event system that can be extended with custom event listeners.

Clerk to Keycloak Event Mapping

Clerk Webhook Event Keycloak Event Type
user.created REGISTER
user.updated UPDATE_PROFILE, UPDATE_EMAIL
user.deleted DELETE_ACCOUNT
session.created LOGIN
session.ended LOGOUT
organization.created Custom (Organizations feature)

For forwarding Keycloak events to external services, see our guide on forwarding events to webhooks. Skycloak provides a built-in HTTP webhook event listener that sends events to any endpoint, similar to Clerk’s webhook functionality.

Backend Middleware Migration

If your backend validates Clerk session tokens, update it to validate Keycloak JWTs:

Before (Clerk middleware in Express):

const { ClerkExpressRequireAuth } = require('@clerk/clerk-sdk-node');
app.use('/api', ClerkExpressRequireAuth());

After (Keycloak JWT validation):

const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');

const client = jwksClient({
  jwksUri: `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/certs`,
  cache: true,
  rateLimit: true
});

function getKey(header, callback) {
  client.getSigningKey(header.kid, (err, key) => {
    callback(err, key?.getPublicKey());
  });
}

function requireAuth(req, res, next) {
  const token = req.headers.authorization?.replace('Bearer ', '');
  if (!token) return res.status(401).json({ error: 'No token provided' });

  jwt.verify(token, getKey, {
    issuer: `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}`,
    algorithms: ['RS256']
  }, (err, decoded) => {
    if (err) return res.status(401).json({ error: 'Invalid token' });
    req.user = decoded;
    next();
  });
}

app.use('/api', requireAuth);

Step 7: Social Login Migration

If you used social login providers in Clerk (Google, GitHub, Apple, etc.), configure the same providers as identity providers in Keycloak.

The key difference is that you will create new OAuth apps with each social provider (Google Cloud Console, GitHub Settings, etc.) pointing to your Keycloak redirect URI:

https://auth.example.com/realms/my-app/broker/{provider}/endpoint

Users who signed up via social login will need to link their existing Keycloak account to the social provider on first login. Keycloak handles this automatically if the email matches, based on the identity provider’s “First Login Flow” configuration.

For handling RBAC during social login, you can map social provider attributes to Keycloak roles using identity provider mappers.

Migration Rollout Strategy

A phased approach minimizes risk:

Phase 1: Parallel Running (Week 1-2)

  • Set up Keycloak and import users
  • Keep Clerk running for existing sessions
  • Test all flows against Keycloak in staging

Phase 2: Switch New Signups (Week 2-3)

  • Point registration to Keycloak
  • Existing sessions continue through Clerk
  • New users authenticate through Keycloak

Phase 3: Full Cutover (Week 3-4)

  • Redirect all authentication to Keycloak
  • Existing Clerk sessions expire naturally
  • Users re-authenticate through Keycloak
  • Monitor audit logs for login failures

Phase 4: Cleanup (Week 4+)

  • Remove Clerk SDK and dependencies
  • Delete Clerk application
  • Update documentation

Post-Migration Verification

After migration, verify these items:

  • All users can sign in (check Keycloak event logs)
  • Social login providers work correctly
  • JWT claims match what your backend expects (use the JWT Token Analyzer)
  • Token refresh works for long-lived sessions
  • Password reset flow works end to end
  • MFA enrollment works for users who had it enabled in Clerk

If you are provisioning users from an external directory, configure SCIM in Keycloak to automate user lifecycle management. You can test your SCIM endpoints with the SCIM Endpoint Tester.

Why Teams Choose Keycloak Over Clerk

Factor Clerk Keycloak
Pricing model Per MAU Free (open source)
Self-hostable No Yes
SAML support Enterprise plan only Built-in
Fine-grained authz No Yes (UMA, policies)
Custom auth flows Limited Fully customizable SPIs
Protocol support OIDC only OIDC, SAML, OAuth 2.0
Theme customization CSS-level Full template control
User federation No LDAP, Active Directory, custom

For teams that want the power of Keycloak without the operational overhead of self-hosting, Skycloak provides managed Keycloak instances with built-in monitoring, automatic updates, and enterprise-grade SLAs. See our pricing to find the right plan for your migration.

Frequently asked questions

Can I migrate Clerk password hashes to Keycloak?

Clerk does not expose password hashes through its public API. You can contact Clerk Support to request a bcrypt hash export — if provided, those hashes can be imported directly into Keycloak using the credential representation in the Admin REST API. If you cannot obtain the hashes, the alternative is to import users without passwords and trigger a password reset email for each one via Keycloak’s execute-actions-email endpoint.

How long does a Clerk to Keycloak migration take?

The guide estimates two to four weeks for a typical application. Week one and two cover setting up Keycloak and importing users while keeping Clerk running. Week three switches new signups to Keycloak. Week three to four completes the full cutover, allowing existing Clerk sessions to expire naturally. Cleanup and Clerk cancellation happen in week four and beyond.

What replaces Clerk’s prebuilt React components in Keycloak?

Authentication in Keycloak happens via redirect to the Keycloak-hosted login page rather than embedded components. The login page is fully customizable using Keycloak themes, giving you complete control over branding. For user state management in React, you replace Clerk’s useAuth() hook with a context provider built around keycloak-js or a library like react-oidc-context.

How do Clerk JWT templates map to Keycloak?

Clerk’s sessionClaims and JWT templates are replaced by Keycloak Protocol Mappers configured on the client or client scope. You can map user attributes, realm roles, and client roles into any claim name and JSON structure without writing custom code. Use the JWT Token Analyzer to compare Clerk-issued and Keycloak-issued tokens during the transition.

What happens to users who signed up via social login in Clerk?

Users who authenticated through social providers (Google, GitHub, etc.) in Clerk have no password to migrate. Configure the same providers as Identity Providers in Keycloak, pointing to new OAuth credentials whose redirect URI is your Keycloak broker endpoint. When these users log in for the first time through Keycloak, the First Login Flow automatically links their social identity to the existing account if the email address matches.

Further Reading

Guilliano Molaire
Written by Guilliano Molaire Founder

Guilliano is the founder of Skycloak and a cloud infrastructure specialist with deep expertise in product development and scaling SaaS products. He discovered Keycloak while consulting on enterprise IAM and built Skycloak to make managed Keycloak accessible to teams of every size.

Ready to simplify your authentication?

Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.

© 2026 Skycloak. All Rights Reserved. Design by Yasser Soliman