Which password hashes Insites can check, and how to move WordPress, phpBB and MD5 passwords so people keep the password they have.
This page is for anyone moving people from another platform to Insites, who wants those people to keep the password they already have. It shows which password formats Insites can check, and how to move the rest so each person signs in once with their old password and is then moved on to an ordinary Insites password.
Note: The Insites Migration Tool at migrate.insites.io will do all of this for you, without code, once its WordPress connector ships. The connector moves people and their passwords. Until then, use the steps on this page. To move people between two Insites instances, the tool already moves their passwords today. See Migrating to Insites.
Everything on this page was measured end to end on an Insites instance, with throwaway people who were deleted afterwards.
You load people with the import_users mutation. It stores any value you give it as encrypted_password, and it does not check the value. But the instance checks bcrypt and nothing else. So only a bcrypt hash works after the import.
| Hash format | Where it comes from | What to do | What the password check does |
|---|---|---|---|
$2a$, $2b$, $2y$ (any cost) | Insites, Siteglide, WordPress bcrypt plugins, Laravel, most PHP password_hash | Import it as encrypted_password. It works as it is. | True for the right password, false for a wrong one |
$wp$2y$... | WordPress 6.8 and later | Remove the leading $wp and import the rest. Check the WordPress pre-hash at sign-in. | False for the password itself. True for the WordPress pre-hash |
$P$, $H$ | WordPress before 6.8 (and every account not signed in since), phpBB | Do not import it. Keep it as a legacy hash. | The whole query fails with invalid hash |
| 32 hex characters (MD5) | WordPress before 2.5, many old PHP sites | Do not import it. Keep it as a legacy hash. | The whole query fails with invalid hash |
$6$, $1$, $argon2id$, pbkdf2_sha256$..., plain text | Linux crypt, Argon2, Django and others | Do not import it. These people reset their password. | The whole query fails with invalid hash |
A person imported with no encrypted_password is safe. The password check answers false for them.
A foreign hash cannot ride on the person either. import_users did not keep a properties value for a key that the user schema does not declare. Keep a legacy hash in a table of its own.
WordPress 6.8 and later stores a password as '$wp' . password_hash(base64_encode(hash_hmac('sha384', $password, 'wp-sha384', true)), PASSWORD_BCRYPT). So the hash is bcrypt, but of a pre-hash, not of the password itself.
$wp from each hash.encrypted_password.compute_hmac filter makes exactly the pre-hash WordPress made:{%- assign pre = password | compute_hmac: 'wp-sha384', 'sha384', 'base64' -%}
On the first sign-in that matches, set the instance's own password. The Sign-In Page section below shows how.
WordPress before 6.8 and phpBB use phpass hashes, which start with $P$ or $H$. Very old sites use plain MD5, 32 hex characters. The instance cannot check either, so treat them as legacy hashes:
encrypted_password.legacy_password, with the person's user id and the hash.phpass is MD5 repeated 2 to the power n times, where n is the position of the fourth character of the hash in ./0-9A-Za-z. WordPress uses B, which is 8,192 rounds. In Liquid, 8,192 rounds took 76 milliseconds. The filter digest: 'md5', 'none' gives the raw 16 bytes that each round builds on.
The function answers with three values: match is true when the password matches the hash, upgrade is true when the person should now get the instance's own password, and kind says which format the hash was (phpass, md5 or unknown).
app/lib/legacy_password_check.liquid
{%- comment -%} app/lib/legacy_password_check.liquid -> { match, upgrade, kind } {%- endcomment -%}
{%- assign res = { "match": false, "upgrade": false, "kind": "unknown" } -%}
{%- assign pfx = stored | slice: 0, 3 -%}
{%- if pfx == '$P$' or pfx == '$H$' -%}
{%- assign res["kind"] = 'phpass' -%}
{%- assign itoa = './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' | split: '' -%}
{%- assign hexd = '0123456789abcdef' | split: '' -%}
{%- assign imap = {} -%}{%- for ch in itoa -%}{%- assign imap[ch] = forloop.index0 -%}{%- endfor -%}
{%- assign hmap = {} -%}{%- for ch in hexd -%}{%- assign hmap[ch] = forloop.index0 -%}{%- endfor -%}
{%- assign c = stored | slice: 3, 1 -%}
{%- assign lg = imap[c] -%}
{%- if lg >= 7 and lg <= 30 and stored.size == 34 -%}
{%- assign cnt = 1 -%}{%- for i in (1..lg) -%}{%- assign cnt = cnt | times: 2 -%}{%- endfor -%}
{%- assign salt = stored | slice: 4, 8 -%}
{%- assign hh = salt | append: password | digest: 'md5', 'none' -%}
{%- for i in (1..cnt) -%}
{%- if forloop.last -%}{%- assign hx = hh | append: password | digest: 'md5' -%}
{%- else -%}{%- assign hh = hh | append: password | digest: 'md5', 'none' -%}{%- endif -%}
{%- endfor -%}
{%- assign bytes = [] -%}
{%- for j in (0..15) -%}
{%- assign o = j | times: 2 -%}{%- assign c1 = hx | slice: o, 1 -%}
{%- assign o2 = o | plus: 1 -%}{%- assign c2 = hx | slice: o2, 1 -%}
{%- assign bv = hmap[c1] | times: 16 | plus: hmap[c2] -%}
{%- assign bytes << bv -%}
{%- endfor -%}
{%- assign enc = '' -%}
{%- for g in (0..5) -%}
{%- assign i = g | times: 3 -%}{%- assign val = bytes[i] -%}
{%- assign i1 = i | plus: 1 -%}{%- assign i2 = i | plus: 2 -%}
{%- if i1 < 16 -%}{%- assign val = bytes[i1] | times: 256 | plus: val -%}{%- endif -%}
{%- if i2 < 16 -%}{%- assign val = bytes[i2] | times: 65536 | plus: val -%}{%- endif -%}
{%- assign q = val | modulo: 64 -%}{%- assign enc = enc | append: itoa[q] -%}
{%- assign q = val | divided_by: 64 | modulo: 64 -%}{%- assign enc = enc | append: itoa[q] -%}
{%- if i1 < 16 -%}
{%- assign q = val | divided_by: 4096 | modulo: 64 -%}{%- assign enc = enc | append: itoa[q] -%}
{%- assign q = val | divided_by: 262144 | modulo: 64 -%}{%- assign enc = enc | append: itoa[q] -%}
{%- endif -%}
{%- endfor -%}
{%- assign computed = stored | slice: 0, 12 | append: enc -%}
{%- if computed == stored -%}{%- assign res["match"] = true -%}{%- assign res["upgrade"] = true -%}{%- endif -%}
{%- endif -%}
{%- elsif stored.size == 32 -%}
{%- assign res["kind"] = 'md5' -%}
{%- assign m5 = password | digest: 'md5' -%}
{%- if m5 == stored -%}{%- assign res["match"] = true -%}{%- assign res["upgrade"] = true -%}{%- endif -%}
{%- endif -%}
{%- return res -%}
The sign-in page does the upgrade on sign-in. It works in this order:
user_update(id:, user: { password: }), delete the legacy row, and sign the person in.The person's next sign-in is an ordinary one, and their hash reads back as the instance's own $2a$10$ hash.
{% liquid
graphql u = 'users/authenticate', email: email, password: password
if u.user == blank
comment Not the ordinary password. Try the hash the person brought with them. endcomment
graphql row = 'legacy_password/find', email: email
if row
function chk = 'legacy_password_check', stored: row.hash, password: password
else
assign pre = password | compute_hmac: 'wp-sha384', 'sha384', 'base64'
graphql u2 = 'users/authenticate', email: email, password: pre
assign chk = { "match": false }
if u2.user != blank
assign chk = { "match": true }
endif
endif
if chk.match
graphql _ = 'users/set_password', id: person_id, password: password
graphql _ = 'legacy_password/delete', email: email
comment then sign_in as usual endcomment
endif
endif
%}
The example calls four GraphQL queries that you write for your instance:
users/authenticate checks an email and a password with authenticate { password } and returns the user when they match.legacy_password/find returns the person's row in the legacy table.users/set_password sets the person's password with user_update.legacy_password/delete deletes the person's row from the legacy table.After a match, sign the person in with the sign_in tag as usual.
This was measured end to end for $P$, $H$, MD5 and $wp$2y$ hashes. A wrong password was refused and changed nothing. The right one signed the person in, replaced the hash and deleted the legacy row. From then on, the ordinary password check answered true for that password and false for a wrong one.
Never log or print a hash. Delete the legacy table once no row is left in it.
A person who never signs in keeps the legacy hash in the legacy table. When they come back and cannot sign in, offer them the ordinary reset: a link to choose a new password. See Resetting the Password of an Authenticated User.
People whose hash cannot be checked at all, such as $6$, $1$, Argon2, Django hashes or plain text, also reset their password. Import them with no encrypted_password. Or let them sign in with an emailed link instead. See Passwordless Sign-In.
Never put a value that is not bcrypt in encrypted_password. It is worse than no password at all:
authenticate { password } on that person raises invalid hash, and the whole GraphQL result is lost. Any sign-in page that checks a password the ordinary way breaks for them.user_session_create, answers that the password for this user is corrupted, and asks for a valid bcrypt hash.Keep every other format in a table of its own, as shown above, or leave the password empty and let the person reset it.
Didn't quite find what you are looking for or have feedback on how we can make the content better then we would love to hear from you. Please provide us feedback and we will get back to you shortly.