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

This Postgres password hash generator turns a password into the SCRAM-SHA-256 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 SCRAM-SHA-256 generator draws a fresh sixteen-byte salt on every run, 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. A postgres md5 password hash, the literal md5 followed by the hex digest of the password with the role name appended, 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, so a bcrypt hash for Supabase auth users comes from pgcrypto or from a page like this one and never from CREATE ROLE. 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.

pg_hba.conf: scram-sha-256, md5 and trust

pg_hba.conf decides how a connection proves who it is, one line per combination of connection type, database, role and client address, and the method at the end of that line is what a verifier from this page has to be good for.

scram-sha-256 runs the SCRAM-SHA-256 exchange from RFC 7677, in which the client answers a challenge with a proof derived from the salted key and the password itself never crosses the wire, so a captured exchange yields nothing to replay. On the server side the manual describes the stored form as cryptographically hashed and thought to be secure, which is why a string lifted out of pg_authid is not a password. This is the most secure method PostgreSQL ships, with the one caveat that old client libraries do not speak it.

Older, and still on many servers set up before SCRAM existed, md5 is a challenge-response of PostgreSQL's own design, in which the client answers a random salt from the server with an MD5 of its digest and that salt. That keeps the password off the wire and out of plain text on disk, and the manual is direct about what it leaves open. A stolen hash is as good as the password, MD5 itself is no longer considered secure against a determined attacker, and the method is deprecated and due for removal in a future release.

With trust the server compares nothing at all. Anyone who can reach the socket or the port may log in as any role the line allows, superusers included, so the manual confines it to single-user workstations and to Unix sockets protected by file permissions, and calls it seldom reasonable for TCP/IP beyond localhost. A verifier from this page is irrelevant on a trust line, since the server never asks for one.

Moving a database from md5 to scram-sha-256

The manual's order is short. Check that every client library in use speaks SCRAM, set password_encryption = 'scram-sha-256' in postgresql.conf, have every role set a new password, then change the pg_hba.conf lines from md5 to scram-sha-256. Two facts are what make that order safe.

Nothing converts an existing verifier. password_encryption decides only what a plaintext turns into at the moment a password is set, so flipping it changes nothing about the roles you already have, and each keeps its md5 string until someone runs ALTER ROLE ... PASSWORD for it. On PostgreSQL 18.3, a role created with the setting at md5 stored md5c543fa64..., and the same ALTER ROLE with a plaintext after the setting went to scram-sha-256 stored a string beginning SCRAM-SHA-256$4096:. A verifier handed over ready-made is stored as given whatever the setting says, which is why the CREATE ROLE from this page works before the flip as well as after it.

A pg_hba.conf line reading md5 already accepts SCRAM, which is the second fact. In the manual's words, when md5 is the method on the line but the role's password is stored for SCRAM, SCRAM authentication is chosen automatically, while the reverse, an md5 verifier against a scram-sha-256 line, is not accepted at all. So the pg_hba.conf edit can wait until the last md5 verifier is gone, and this query says when that is.

SELECT rolname, left(rolpassword, 4) AS scheme
FROM pg_authid
WHERE rolpassword IS NOT NULL
ORDER BY rolname;

Rows reading md58 are still on the old scheme and rows reading SCRA have moved. Once the column is uniform, switch the lines, reload, and nothing that connects should notice.

Set a password without psql \password

\password exists because ALTER ROLE app_user PASSWORD 'plaintext' carries the secret to the server in clear, where the manual warns it may end up in the client's command history or the server log. psql hashes the password on the client and sends the verifier instead. This page does the same job in a browser tab, and the two statements it writes are the shape psql would send, a CREATE ROLE carrying the SCRAM verifier and an ALTER ROLE carrying the md5 one.

CREATE ROLE app_user LOGIN PASSWORD 'SCRAM-SHA-256$4096:Y99P0pGJwzLDLJG0cOVAMA==$i7XEmGHu1e4Ut961Du4ZPNXEAfRzrVhUYnR92hHbE8g=:KwBkqnC/ZGim7yVXt2CI8orVOHcdigKBNh5gC0OI4eI=';
ALTER ROLE app_user PASSWORD 'md58d572a089a73b734c1b85a488c1e0507';

Both ran on PostgreSQL 18.3, and pg_authid.rolpassword came back equal to the string that went in, character for character. Paste either into a provisioning script and the plaintext never has to exist outside this tab and a password manager.

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.

It does, because the manual made that the transition path. When the line says md5 and the role's stored password is a SCRAM verifier, the server runs SCRAM authentication instead. The reverse does not hold, since a password stored as md5 can only be used with the md5 and password methods, so the pg_hba.conf change is the last step of a migration rather than the first.

The server's own scram_iterations defaults to 4096 and the field here matches it. Raising it slows every guess an attacker makes and every login you make by the same factor, and the manual adds a subtler point, that a role whose verifier carries a different count from the server setting can be told apart from a nonexistent role by watching how connection attempts are answered. Keep the two equal unless you are changing both.