Skip to content

Migrating users off another system

Migrating users off another system

Nobody changes identity provider if it means emailing every customer a password reset. Two mechanisms exist so you do not have to, and they are complementary rather than alternatives.

First: bulk import

UserImport moves users with their existing password hashes. Each foreign hash is verified by the same registry the platform uses for its own, and transparently upgraded to the platform hasher on that person's first successful login. Nobody notices anything.

app(UserImport::class)->import($organizationId, $users, new ImportOptions(upsert: true));

This is the better answer whenever it is available: it is one pass, it is auditable, and the old system can be switched off the same day.

It requires that you can export the hashes.

When you cannot export: delegated authentication

Often you cannot. The hashes sit in a system with no dump, or in a format nothing recognises, or behind an API somebody else owns.

So the verification moves instead of the data. An email nobody here knows is offered to the old system, and if it says yes, the person is created here as a result of the login that just succeeded. The next time they sign in they are an ordinary local user and the old system is never consulted again.

Bind a source:

use Cbox\Id\Migration\Contracts\LegacyCredentialSource;
use Cbox\Id\Migration\Sources\DatabaseCredentialSource;

$this->app->singleton(LegacyCredentialSource::class, fn ($app) => new DatabaseCredentialSource(
    $app['db'],
    $app[HashVerifier::class],
    connection: 'legacy',          // configure it read-only
    table: 'users',
    columns: [
        'email' => 'email',
        'name' => 'name',
        'password' => 'password',
        'verified_at' => 'email_verified_at',
    ],
));

That covers the common case by a distance: an old Laravel, Rails or PHP app with a users table of bcrypt hashes in a database you still have credentials for.

For anything else — a mainframe, a SaaS with an authentication endpoint and no export — there is an HTTP bridge:

new HttpCredentialSource($app[Factory::class], $app[UrlGuard::class],
    url: 'https://legacy.acme.com/cbox-verify',
    secret: config('services.legacy.secret'),
);

Your handler receives a signed POST and answers with the person, or refuses:

{ "email": "[email protected]", "name": "Ada Lovelace", "email_verified": true }

Returning password_hash as well lets the person keep their password here verbatim; if your system cannot expose it, omit it and the password they just proved is hashed with the platform hasher instead. Both are legitimate.

The request is signed exactly the way external actions are — X-Cbox-Signature: t=…,v1=… over "{timestamp}.{body}" — so a verifier you have already written works here unchanged.

Why not a script sandbox? Auth0 solves this by running your JavaScript inside their runtime. That is a code-execution surface in the authentication path, where a customer's bug becomes the provider's incident. An HTTP hop buys the same flexibility with the blast radius on the side that wrote the code.

Declaring it from your app instead

Configuring the URL in one place and the secret in another is two places that drift. Your app already declares facts about itself through the manifest — its roles, its permissions, versioned with the deploy — and where its old login lives is the same kind of fact:

export default defineAuthz({
  roles: [...],
  legacyLogin: {
    url: 'https://acme.com/api/cbox-legacy',
    secret: process.env.CBOX_LEGACY_SECRET!,
  },
})

A declaration arrives inert. Everything else in a manifest affects only the app that declared it; this one names a URL that every unknown email and the password typed with it will be offered to, on the environment's whole sign-in path. A client holding apps.manifest that could switch that on by itself would be a credential harvester with a scope for the purpose.

So it is stored, shown to an operator beside the app that proposed it, and does nothing until a person approves it. Re-declaring the same URL keeps that approval — a routine redeploy republishes the same manifest, and an approval people click through on every release is not a control. Re-declaring a different URL drops it, because "the app changed where passwords go" is precisely the event that must not pass unnoticed: a compromised deploy pipeline is otherwise one manifest push away from redirecting the login path.

The URL is stored readable, because an operator has to see it before approving and a value nobody can inspect is a value nobody can check. The secret is sealed.

Writing the handler

@cboxdk/id-js ships the handler so you do not write the signature check:

export const POST = createLegacyVerifier({
  secret: process.env.CBOX_LEGACY_SECRET!,
  async verify(email, password) {
    const row = await db.users.findByEmail(email)
    if (!row || !(await argon2.verify(row.password, password))) return null

    return { email: row.email, name: row.name, emailVerified: !!row.confirmedAt }
  },
})

Return null for a wrong password. Throwing is different and is answered with a 503: your store could not decide, which is not the same as saying no, and the fail-closed rule below turns it into a refusal that is distinguishable in the logs.

Two rules, and why they are not negotiable

A user who exists here is never offered to the old system. Once somebody has migrated, their password is the one in this platform, full stop. Consulting the old system for them would let a password they changed here be bypassed by the one still sitting there — and the old system is, by definition, the one nobody is maintaining any more.

A source that cannot answer refuses the sign-in. If the legacy system is down, slow or misconfigured, an un-migrated person cannot sign in. The alternative — letting them through because we could not check — turns an outage in the system you are migrating off into an authentication bypass. Migrated users are unaffected, because of the first rule, so the blast radius is exactly the tail you have not moved yet.

Attaching people to organizations

The platform creates the subject; what they belong to is yours to decide. Listen for UserMigrated, which carries both sides — the subject as this platform now knows them, and the legacy record with whatever role or tenant it held:

Event::listen(UserMigrated::class, function (UserMigrated $event): void {
    app(Memberships::class)->add($orgId, $event->subject->id, MembershipRole::from($event->legacy->role));
});

Finishing the migration

Delegated authentication is a ramp, not a destination. Watch how many sign-ins still go through the bridge; when the tail is thin enough to be worth a password reset, unbind the source and send that email to the remainder. Leaving it bound forever means the old system's availability is permanently part of your login path.

Networking

The HTTP bridge pins DNS and refuses private address ranges by default, like every other outbound call this package makes. A legacy system is very often on a private network — that is what makes it legacy — so CBOX_ID_MIGRATION_VERIFY_URL=false exists to say so deliberately. Plain http is refused regardless: a credential in the clear is readable by everything on the path, and no configuration flag should be able to permit that.