PostgreSQL Password Hash Generator: SCRAM-SHA-256, MD5, bcrypt

This SCRAM-SHA-256 generator turns a PostgreSQL password into the verifier string pg_authid stores, next to the legacy md5 form and a bcrypt hash for Supabase auth.users. Each result arrives with the SQL that installs it, and the hashing runs in your browser.

Normalised with NFKC before the key is derived, which is the part of SASLprep the browser can do.

Used in the SQL below. The md5 scheme also folds it into the digest, which is why md5 verifiers break when a role is renamed.

Each step up doubles the work done to check one password.

4096 is the scram_iterations default. Between 1 and 1,000,000.

Every run draws a new salt, so the SCRAM verifier below changes each time.

SCRAM-SHA-256 verifier

The verifier appears here.

What pg_authid holds for a role created while password_encryption is scram-sha-256. The salt is redrawn on every run, so two runs of the same password differ.

CREATE ROLE

The CREATE ROLE statement appears here.

md5 verifier

The md5 verifier appears here.

The literal string md5 followed by md5(password || role name). PostgreSQL 18 still stores it, and warns that setting one is deprecated.

ALTER ROLE

The ALTER ROLE statement appears here.

bcrypt hash

The bcrypt hash appears here.

Labelled $2a$, the prefix pgcrypto's crypt() reads as bcrypt. For Supabase auth.users.encrypted_password on a local stack.

Supabase auth.users

The update for auth.users appears here, with the comparison pgcrypto makes.

Hashing runs on Web Crypto in this tab. The password is never sent anywhere, and Ctrl or Cmd with Enter runs the button in this panel.

What it does and how to use it

Type the password and press Generate the hashes, or use Ctrl+Enter. Three results appear at once, because a Postgres role, an older verifier and a Supabase auth row each want a different string for the same secret. The role name feeds the SQL snippets underneath it, and the md5 scheme folds that name into the digest as well. A SCRAM verifier holds no name at all, so renaming a role costs you the md5 one and nothing else.

Switch to Verify to go the other way. Paste a hash together with the password you think produced it, and the prefix decides what happens next. Given a SCRAM verifier, the page pulls the salt and the iteration count out of the string itself and derives both keys again. bcrypt runs its own comparison, and an md5 digest needs the role name filled in, since the scheme hashed the two together in the first place.

How PostgreSQL stores a password

Write CREATE ROLE app_user LOGIN PASSWORD 'plaintext' and the server keeps no plaintext. With password_encryption at its default of scram-sha-256, what lands in pg_authid.rolpassword is four fields glued into one string: the algorithm name, the iteration count, a sixteen-byte random salt in base64, then the StoredKey and ServerKey that RFC 5802 derives through PBKDF2-HMAC-SHA-256. PostgreSQL 18.3 writes 4096 iterations, the value of the scram_iterations setting.

SELECT rolpassword FROM pg_authid WHERE rolname = 'app_user';
-- SCRAM-SHA-256$4096:ONzM1YR...g==$ph7/h18pel...=:JnuVWMsaGs...=

Hand the server a finished verifier in place of a plaintext and it stores that string exactly as given, which is what makes a generated one usable in a provisioning script. An md5 verifier is accepted the same way and PostgreSQL 18 still stores it, although CREATE ROLE and ALTER ROLE now emit a deprecation warning whenever they build one, which the md5_password_warnings setting turns off.

Neither scheme covers bcrypt, because PostgreSQL never uses bcrypt for role passwords. It arrives through the pgcrypto extension, where crypt() and gen_salt('bf', ...) are what a local Supabase seed.sql reaches for when it fills auth.users.encrypted_password. Called with no cost argument, gen_salt('bf') picks 6.

The bcrypt prefix pgcrypto will not read

JavaScript bcrypt implementations label their output $2b$, while the version of the algorithm compiled into pgcrypto answers to $2a$ and $2x$ and stays quiet about anything else. Given a $2b$ string, crypt() falls through to traditional DES, reads $2 as the salt, and returns a twelve-character value that compares unequal to what you passed in, so the login fails with nothing in the logs to explain it.

Running both against PostgreSQL 18.3 settled it. One salt and digest verified under $2a$ and failed under $2b$, so every hash this page produces carries the $2a$ label, which is the prefix gen_salt('bf') writes itself.

FAQ

Frequently asked questions

The page makes no network request at any point. The SCRAM derivation runs on the Web Crypto API in this tab, and the md5 and bcrypt values are computed by JavaScript beside it. Whatever you type lives in the tab's memory until you close it, since there is no endpoint on our side that receives a password.

Not character for character, and it does not need to. SCRAM draws a fresh random salt every time, so the same password yields a different verifier on each run. Both of them authenticate the same login, because the server derives its key from the salt stored in the verifier rather than comparing strings.

Reversing it is not how any of these schemes come apart. An attacker instead guesses passwords and hashes each guess, which is exactly what the iteration count and the bcrypt cost are there to slow down. A stolen verifier is still worth rotating the password over.

Cost 10 is the default here, and each step up doubles the work of checking one password, which seed data rarely justifies. Note that pgcrypto's own gen_salt('bf') defaults to 6, so a hash made by crypt() without an explicit cost is cheaper than this one.

Because the scheme hashes the password and the role name together, then prefixes the literal string md5. Two roles with the same password therefore store different digests, and renaming a role leaves its md5 verifier unusable. PostgreSQL 18 warns that setting one is deprecated.

Using it costs nothing and asks for no account. There is no server side to rate-limit or log against, so how many hashes you generate is your own business.

Once the role exists, the connection string builder assembles the URI or psql command that uses it. For a Supabase local stack, the guide on seeding auth.users covers the identity row and the token columns that an insert also needs.