1. Docs
  2. Identities
  3. Migrate Existing Users

Migrate Existing Users

Move an existing user base into Canopy with the password hashes it already has, so nobody has to reset a password on day one.

What carries over

A migration creates each identity through the public API with the password hash the old system stored, instead of a password. Canopy keeps the hash as it is, with the algorithm it is in, and verifies a sign-in the way that system did. The person signs in with the password they already have; the first sign-in that proves it replaces the hash with Canopy's own argon2id hash, so within one sign-in per active user the whole base is on Canopy's hashing. Names, email addresses, an external_id for the old system's identifier, metadata, and a starting role all travel in the same request.

Hashes travel from your backend to Canopy's API and nowhere else: password_hash is taken by API Create and its bulk endpoint only, never by the Console, the Admin Workspace or the CSV importer. A hash in a spreadsheet is a leak waiting to happen.

The hashes Canopy accepts

Send the stored hash string as it is, with password_hash_algorithm naming its format. Canopy refuses a hash that is not written the way its algorithm writes one (400 identity.password_hash_invalid), so a bad export fails at import rather than at every sign-in after it.

bcrypt: the modular crypt string as stored, $2a$, $2b$ or $2y$. What Rails, Laravel, Spring Security and most Node applications keep.argon2id: the PHC string, $argon2id$v=19$m=…,t=…,p=…$salt$hash.pbkdf2_sha256: Django's encoding, pbkdf2_sha256$iterations$salt$hash, with the hash base64.scrypt: Django's encoding, scrypt$N$salt$r$p$hash, with the hash base64.

A hash the old system made with a pepper of its own cannot be verified without that pepper, and MD5, SHA-1 and unsalted hashes are not accepted at all: they are too weak to keep. Those users take the rolling reset below.

Run the migration

A migration is an export, an API key, and a loop over the bulk endpoint.

Five steps
Export each user's email address, names and stored hash string from the old system, with whatever identifier your application uses for them.Create an API key with identity.manage in the target Environment (Console → Integrations → API Keys). The key names the Environment, so nothing else is sent.Call POST /api/v1/identities/bulk-create in chunks of up to 200, each row carrying password_hash and password_hash_algorithm in place of password, and external_id for the old identifier. Each row succeeds or fails on its own, and an Idempotency-Key header makes a retried chunk safe.Mark the addresses the old system had already verified with POST /api/v1/identities/:id/verify-email. Until an address is verified, a sign-in answers 401 auth.email_not_verified and Hosted Login mails a verification link.Point your application at Canopy. People sign in as before, through Hosted Login or the Direct API, with the password they already have.

A bulk row with an imported hash, the hash string exported as PASSWORD_HASH

curl -X POST https://auth.canopy-io.com/api/v1/identities/bulk-create \
  -H "X-API-Key: $CANOPY_API_KEY" \
  -H "Idempotency-Key: migration-chunk-0001" \
  -H "Content-Type: application/json" \
  -d '{
    "identities": [
      {
        "email": "alex@acme.com",
        "first_name": "Alex",
        "last_name": "Singh",
        "external_id": "legacy:42",
        "password_hash": "'"$PASSWORD_HASH"'",
        "password_hash_algorithm": "bcrypt"
      }
    ]
  }'

A row that fails answers in the same results array as the rest, with its code: identity.password_hash_invalid for a hash not written its algorithm's way, identity.email_conflict for an address already in the Account. Fix the export and resend those rows.

What the first sign-in does

Canopy never sees a plaintext password at import, so two things the create would have done wait for the first sign-in that proves the password:

The hash becomes Canopy's own. A sign-in that proves an imported password replaces the hash with Canopy's own argon2id hash; from then on the row is indistinguishable from one Canopy created. A wrong password counts against the lockout like any other, and leaves the imported hash in place.The breach check runs. The password goes through the same HaveIBeenPwned check a new password gets. One found in a known breach is not let in: Canopy mails the person a reset link and refuses the sign-in with 401 auth.password_reset_required; Hosted Login says so on the page, and your own sign-in form should too. Once they set a new password, they sign in normally. Expect a small share of any migrated base to land here.

When a hash cannot move

When the old system cannot hand over a usable hash, create the identities without a password and let people set one at their own pace. Nothing is lost but the password itself.

Send a reset from your backend. POST /api/v1/identities/:id/password/reset mails a reset link; the person sets a password and signs in.Invite them instead. API Invite mails a link that creates the identity and takes a password at acceptance.Leave it to forgot-password. An identity with no password can start a reset from your sign-in page's forgot-password link whenever they next need in.Pair with SCIM. Where the old system is a directory, directory sync keeps the people in step after the one-time move.
Environment
API version
v1.0
On this page Was this page helpful?

Tell us how we can improve this guide.