Migrate to Kinde for user authentication
Get started
Drip-feed migration lets you move users from your legacy auth system to Kinde progressively as they log in — no downtime, no forced password resets, and no bulk password export required.
Each user is migrated the first time they sign in: Kinde validates their password against the legacy system, creates their account in Kinde, and takes over authentication from that point forward. Once all users have migrated naturally, you can decommission the legacy system.
The user:existing_password_provided trigger only fires when the user’s email already exists in Kinde. Run a bulk import of user email addresses (without passwords) before enabling this workflow. Here is an example CSV format of your exported users:
emailuser1@example.comuser2@example.comuser3@example.comGo to your Kinde dashboard > Settings > Environment > Attack protection > Enumeration protection and turn it on. This is required for the workflow to function correctly.
This workflow updates the user password in Kinde, so you need to set up a Kinde Management API.
create:usersupdate:user_passwordsKINDE_WF_M2M_CLIENT_IDKINDE_WF_M2M_CLIENT_SECRET - Ensure this is setup with Sensitive flag enabled to prevent accidental sharingYou may need to add additional environment variables to your workflow, such as API URLs or config values. Follow these steps:
If you need to store a sensitive value, ensure it is set up with the Sensitive flag enabled to prevent accidental sharing.
Fork the workflow base template into your GitHub account by selecting Use this template > Create a new repository.
Clone the repo:
git clone https://github.com/your_github_username/workflow-base-template.gitcd workflow-base-templateCreate a new workflow directory:
mkdir kindeSrc/environment/workflows/dripFeedMigrationtouch kindeSrc/environment/workflows/dripFeedMigration/Workflow.tsAdd the workflow code below. Choose the version for your legacy provider, or adapt the generic version for your own system.
If you want to encrypt the password before sending it to your legacy system, use the kinde.secureFetch binding. However, if you don’t need encryption, replace kinde.secureFetch with kinde.fetch — no encryption key is required.
To use the secure fetch, follow the steps:
Go to Workflows and select the Drip-feed migration workflow.
Select Encryption keys in the menu.
Select Add encryption key. A dialog appears, enabling you to copy the key. You need to copy it immediately, as it cannot be viewed again.
After you copy the key, select Close. If this is the first key you have added, it will automatically be made active.
Add the key to your code, to decrypt data sent from Kinde.
Learn how to Decrypt workflow‑encrypted payloads.
Use the following code as a starting point if your legacy system has a custom password validation endpoint.
import { onExistingPasswordProvidedEvent, WorkflowSettings, WorkflowTrigger, createKindeAPI,} from "@kinde/infrastructure";
export const workflowSettings: WorkflowSettings = { id: "dripFeedMigration", name: "Drip-feed migration", trigger: WorkflowTrigger.ExistingPasswordProvided, failurePolicy: { action: "stop" }, bindings: { "kinde.fetch": {}, "kinde.secureFetch": {}, "url": {}, },};
export default async function Workflow(event: onExistingPasswordProvidedEvent) { const { providedEmail, password, hasUserRecordInKinde } = event.context.auth;
// Already in Kinde — nothing to do if (hasUserRecordInKinde) return;
// Validate the password against your legacy system const legacyResponse = await kinde.secureFetch("https://your-legacy-api.example.com/validate", { method: "POST", responseFormat: "json", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: providedEmail, password }), });
if (!legacyResponse.ok) return;
// Create user in Kinde and set their password const kindeAPI = await createKindeAPI(event); const { user } = await kindeAPI.post("/api/v1/user", { profile: { email: providedEmail }, identities: [{ type: "email", details: { email: providedEmail } }], });
await kindeAPI.put(`/api/v1/users/${user.id}/password`, { password, is_temporary_password: false, });}Check out the provider-specific variants below.
Set the following environment variables in Kinde:
AUTH0_DOMAINAUTH0_AUDIENCEAUTH0_CLIENT_IDAUTH0_CLIENT_SECRETUse the following code to validate credentials using Auth0’s Resource Owner Password Flow:
import { onExistingPasswordProvidedEvent, WorkflowSettings, WorkflowTrigger, createKindeAPI, getEnvironmentVariable,} from "@kinde/infrastructure";
export const workflowSettings: WorkflowSettings = { id: "dripFeedMigrationAuth0", name: "Drip-feed migration (Auth0)", trigger: WorkflowTrigger.ExistingPasswordProvided, failurePolicy: { action: "stop" }, bindings: { "kinde.fetch": {}, "kinde.env": {}, "url": {}, },};
export default async function Workflow(event: onExistingPasswordProvidedEvent) { const { providedEmail, password, hasUserRecordInKinde } = event.context.auth;
if (hasUserRecordInKinde) return;
// Validate against Auth0 Resource Owner Password Flow const auth0Domain = getEnvironmentVariable("AUTH0_DOMAIN")?.value; const auth0Response = await kinde.fetch( `https://${auth0Domain}/oauth/token`, { method: "POST", responseFormat: "json", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ grant_type: "password", username: providedEmail, password, audience: getEnvironmentVariable("AUTH0_AUDIENCE")?.value, client_id: getEnvironmentVariable("AUTH0_CLIENT_ID")?.value, client_secret: getEnvironmentVariable("AUTH0_CLIENT_SECRET")?.value, }), } );
if (!auth0Response.ok) return;
const kindeAPI = await createKindeAPI(event); const { user } = await kindeAPI.post("/api/v1/user", { profile: { email: providedEmail }, identities: [{ type: "email", details: { email: providedEmail } }], });
await kindeAPI.put(`/api/v1/users/${user.id}/password`, { password, is_temporary_password: false, });}Set the following environment variables in Kinde:
AWS_REGIONCOGNITO_CLIENT_IDCOGNITO_USER_POOL_IDUses Cognito’s ADMIN_USER_PASSWORD_AUTH flow. Ensure this flow is enabled on your Cognito App Client before deploying. You will also need to sign the request with AWS Signature V4 — add your signing logic or use a Lambda proxy that handles auth.
Use the following code in your workflow:
import { onExistingPasswordProvidedEvent, WorkflowSettings, WorkflowTrigger, createKindeAPI, getEnvironmentVariable,} from "@kinde/infrastructure";
export const workflowSettings: WorkflowSettings = { id: "dripFeedMigrationCognito", name: "Drip-feed migration (Cognito)", trigger: WorkflowTrigger.ExistingPasswordProvided, failurePolicy: { action: "stop" }, bindings: { "kinde.fetch": {}, "kinde.env": {}, "url": {}, },};
export default async function Workflow(event: onExistingPasswordProvidedEvent) { const { providedEmail, password, hasUserRecordInKinde } = event.context.auth;
if (hasUserRecordInKinde) return;
// Validate against Cognito ADMIN_USER_PASSWORD_AUTH const awsRegion = getEnvironmentVariable("AWS_REGION")?.value; const cognitoResponse = await kinde.fetch( `https://cognito-idp.${awsRegion}.amazonaws.com/`, { method: "POST", responseFormat: "json", headers: { "Content-Type": "application/x-amz-json-1.1", "X-Amz-Target": "AWSCognitoIdentityProviderService.AdminInitiateAuth", }, body: JSON.stringify({ AuthFlow: "ADMIN_USER_PASSWORD_AUTH", ClientId: getEnvironmentVariable("COGNITO_CLIENT_ID")?.value, UserPoolId: getEnvironmentVariable("COGNITO_USER_POOL_ID")?.value, AuthParameters: { USERNAME: providedEmail, PASSWORD: password, }, }), } );
if (!cognitoResponse.ok) return;
const kindeAPI = await createKindeAPI(event); const { user } = await kindeAPI.post("/api/v1/user", { profile: { email: providedEmail }, identities: [{ type: "email", details: { email: providedEmail } }], });
await kindeAPI.put(`/api/v1/users/${user.id}/password`, { password, is_temporary_password: false, });}Set the following environment variables in Kinde:
AZURE_B2C_TENANTAZURE_B2C_ROPC_POLICYAZURE_B2C_CLIENT_IDAZURE_B2C_SCOPEUses the Azure B2C Resource Owner Password Credentials (ROPC) token endpoint. Use the following code in your workflow:
import { onExistingPasswordProvidedEvent, WorkflowSettings, WorkflowTrigger, createKindeAPI, getEnvironmentVariable,} from "@kinde/infrastructure";
export const workflowSettings: WorkflowSettings = { id: "dripFeedMigrationAzureB2C", name: "Drip-feed migration (Azure B2C)", trigger: WorkflowTrigger.ExistingPasswordProvided, failurePolicy: { action: "stop" }, bindings: { "kinde.fetch": {}, "kinde.env": {}, "url": {}, },};
export default async function Workflow(event: onExistingPasswordProvidedEvent) { const { providedEmail, password, hasUserRecordInKinde } = event.context.auth;
if (hasUserRecordInKinde) return;
const tenant = getEnvironmentVariable("AZURE_B2C_TENANT")?.value; const policy = getEnvironmentVariable("AZURE_B2C_ROPC_POLICY")?.value; const clientId = getEnvironmentVariable("AZURE_B2C_CLIENT_ID")?.value; const scope = getEnvironmentVariable("AZURE_B2C_SCOPE")?.value;
const body = new URLSearchParams({ grant_type: "password", client_id: clientId, scope: `openid ${scope}`, username: providedEmail, password, response_type: "token", });
const azureResponse = await kinde.fetch( `https://${tenant}.b2clogin.com/${tenant}.onmicrosoft.com/${policy}/oauth2/v2.0/token`, { method: "POST", responseFormat: "json", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: body.toString(), } );
if (!azureResponse.ok) return;
const kindeAPI = await createKindeAPI(event); const { user } = await kindeAPI.post("/api/v1/user", { profile: { email: providedEmail }, identities: [{ type: "email", details: { email: providedEmail } }], });
await kindeAPI.put(`/api/v1/users/${user.id}/password`, { password, is_temporary_password: false, });}git add .git commit -m "add drip-feed migration workflow"git pushmain branch, and select Next.For full deployment instructions, see Manage code and deployments.
Once the drip-feed is running:
Users who never logged in during the migration period will not be migrated. Decide whether to keep inactive users (force a password reset via the API) or remove them.
If you need help with your migration, contact Kinde support.