Back, after recovery from PGConf.EU!
scram_iterations is not really a server setting. It is the default for a property of each password, written into the password at the moment it is set, and the server itself reads the number on only two later occasions: when someone sets another password, and when someone tries to log in as a role that has no SCRAM password to check, which includes roles that do not exist and roles still on MD5. It also announces the number to every client that connects, which turns out to matter. Everything else about it follows from that.
The default is 4096, the context is user (any role can SET it, and it only governs what that session does next), and the range is 1 to 2147483647. It arrived in PostgreSQL 16, which is also why it does not appear in anything older; before 16 the number was a compile-time constant, and the constant was 4096.
The number is the PBKDF2 iteration count for SCRAM-SHA-256: how many times the client runs HMAC-SHA-256 over the password and salt to derive the key everything else in the exchange is built from. The result of all that hashing is not stored. What pg_authid.rolpassword holds is the iteration count, the salt, and two keys derived from the output, in the format SCRAM-SHA-256$4096:<salt>$<StoredKey>:<ServerKey>. The count is right there in the string, and that is the important fact about this parameter. Change scram_iterations and reload, and nothing happens to any existing password, because each one already knows its own count. pg_dumpall -r emits the string verbatim, so the count survives a restore, a pg_upgrade, and a copy into PgBouncer’s auth_file (PgBouncer 1.25 grew a scram_iterations of its own, for the verifiers it builds from plaintext entries). The only way to move a password to a new count is to set the password again, which means knowing it, which the server does not.
So who pays for the iterations, and when? Setting a password costs the server one derivation, however the password arrives. Send the clear text (CREATE ROLE ... PASSWORD 'secret') and the server derives the keys; on my test VM that took 0.2 seconds at 100,000 iterations and 1.8 seconds at 1,000,000. Send a finished verifier instead, which is what \password, createuser --pwprompt and a pg_dumpall restore all do, and the server stores the string exactly as given, but first runs the whole derivation anyway, to check that it is not the hash of the empty string; the ALTER ROLE for a million-iteration verifier also took 1.8 seconds. (So \password at a high count costs about two seconds on each end, and restoring a dump with a thousand such roles costs the server half an hour.) Since 16 the parameter is GUC_REPORT, so libpq learns the session’s current value and builds the verifier with it: SET scram_iterations = 42 followed by \password produces a password with 42 iterations. I checked. A libpq older than 16 receives the value and ignores it, building with its own built-in 4096 no matter what the server is set to; I verified that with a real 15.9 libpq.so and a server at 100,000, and the resulting password said 4096.
At login time with the scram-sha-256 method, though, the server does almost nothing. The client derives the salted password from the count the server announces, and the server verifies the proof and signs its reply with two HMACs, an XOR, and one SHA-256, none of which depend on the count. I made ten connections each as roles whose passwords had 1, 4096, 100,000, and 1,000,000 iterations, and measured both sides. The backends the postmaster reaped used about 50 ms of CPU for all ten connections in every case. The client used 0.05, 0.14, 1.9, and 18.3 seconds. scram_iterations is a CPU tax that connecting clients pay on your behalf, and the server never notices it except as time spent waiting. PostgreSQL 18 will even show you the wait: with log_connections = 'setup_durations', the connection ready line for the million-iteration role reported authentication=1802.897 ms, which is the server’s own account of sitting idle while the client hashed. (The password method in pg_hba.conf is the exception, because the client sends the clear text and the server does the derivation itself, on every login: 1.7 seconds of backend CPU per connection at a million iterations when I tried it, payable by anyone who cares to send wrong passwords. That method was a bad idea before this parameter existed.)
The per-connection figures on that VM were about two microseconds per iteration, so the default costs roughly 8 ms of client CPU per connection, 100,000 costs about 200 ms, and a million about two seconds. (Michael Paquier’s measurements from 2023 were about three times faster.) With pgbench -C, which reconnects for every transaction, the default managed 75 connections per second from one client against 264 at one iteration. libpq does not cache the derived key between connections, though RFC 7677 allows it, so every connection pays in full. That is the cost of SCRAM as shipped, and the people who notice it are the ones connecting per request without a pooler, who have a different problem than this parameter.
What the iterations buy is resistance to offline guessing if pg_authid leaks: a backup in the wrong bucket, a replica someone walked off with. An attacker holding the string cannot log in with it directly (the StoredKey is a hash of the key the client has to prove it knows), so they are left guessing passwords, and each guess costs them the full iteration count. That is the whole benefit, and it is narrower than it sounds. It only matters for passwords a human chose. A 32-character random application password is not guessable at one iteration, let alone 4096, so raising the count for a fleet of service roles buys nothing and charges every connection for it. And RFC 5802’s security considerations point out that the stored string plus one eavesdropped exchange, or one impersonated server, lets the attacker impersonate the user without ever learning the password. The iteration count does not help with that; sslmode=verify-full, or channel_binding=require, does.
The history explains the default. RFC 7677 (2015) said the count SHOULD be at least 4096, noted that 4096 took about half a second on the mobile handsets of the day, and offered 15,000 as a rule of thumb for a tenth of a second on a modern machine. PostgreSQL 10 hardcoded 4096. In December 2022 Daniel Gustafsson proposed making it a GUC with a default of 15,000 and a floor of 4096, observing that “calculating tens of thousands of hashes per second on a consumer laptop at a 4096 iteration count is no stretch.” The default did not survive review, and the floor went the other way: the committed version (March 2023, for 16) keeps 4096 and allows 1, on the argument that platforms too slow for 4096 iterations of SCRAM were choosing MD5 instead, and SCRAM at one iteration still beats that.
Now the part that got a CVE. The server has to send a salt and a count before it knows whether the password is right, and it must send something plausible for a role that does not exist, or an attacker could enumerate roles by watching which usernames get a challenge. PostgreSQL has done this since 10 with a mock exchange: a salt derived from the username and a per-cluster nonce, and an iteration count. Until August 2026 that count was the compiled-in 4096, which was fine until 16 made the real count configurable. Set scram_iterations = 100000 and create your roles, and every real role answers i=100000 while every ghost answers i=4096. That is CVE-2026-14672, a user-existence oracle reported by Radim Marek and fixed by Nathan Bossart in 18.6, 17.11 and 16.15 by having the mock use the configured value. I ran a probe against 18.4 and 18.6 side by side (output trimmed):
1 --- 18.4, postgresql.conf scram_iterations = 100000
2 u100k: i=100000
3 u4096: i=4096
4 no_such_role: i=4096
5 --- 18.6, same configuration
6 u100k: i=100000
7 u4096: i=4096
8 no_such_role: i=100000
Read the second block again. The fix did not close the oracle; it moved it. On a patched server the ghosts now match the roles you created after raising the value, and the roles you created before it are the ones that stand out. The documentation’s new note says as much and tells you the cure, which is the only one there is: every password on the cluster at the same count as postgresql.conf. (The mock reads the value from postgresql.conf or the command line, not from ALTER ROLE ... SET or the startup packet, because those are applied after authentication.) Until you have reset every password, the old ones are both the weaker ones and the ones advertising themselves, and you are worse off than before you started. And because the context is user, you cannot enforce the cure: anyone allowed to change their own password can SET scram_iterations = 1 first, and the server has no minimum to stop them. Inventory before you begin, and again afterwards:
1 select coalesce(substring(rolpassword from '^SCRAM-SHA-256\$(\d+):'),
2 left(rolpassword, 3), '(none)') as password_type,
3 count(*)
4 from pg_authid
5 group by 1 order by 2 desc, 1;
There is one more constraint, and the enforcement of it is new this year. Because the server dictates the count and the client does the work, a hostile or compromised server can announce an enormous count and park a client in PBKDF2 for as long as it likes; RFC 5802 mentions this too. libpq, through 18.6 and the 19 release candidate, accepts any count from 1 up, and connect_timeout cannot interrupt the loop: I pointed psql at a stand-in server announcing twenty million iterations with connect_timeout=3, and it burned 39 seconds of CPU before reporting that the timeout had expired. At 2147483647 that would be over an hour on that VM, and a real server would have given up on the connection at authentication_timeout (60 seconds, or about thirty million iterations there) without the client noticing, because it is not listening. Sehrope Sarkuni has a patch set on hackers (May 2026) to make the loop honor connect_timeout and to add a client-side cap of 100,000; Daniel Gustafsson was for the timeout and against another connection parameter. The other drivers already have the cap, and the same people put it there: pgjdbc 42.7.11 (April 2026, CVE-2026-42198) and node-postgres 8.21 both refuse any count above 100,000 unless you raise scramMaxIterations, and PgBouncer 1.26.0 (September 2026, CVE-2026-6669) refuses anything above 1,000,000 from a server outright. Set scram_iterations above 100,000, reset a password, and your Java and Node applications will stop connecting as that role, with an error about the driver’s own limit rather than anything on the server. [PLACEHOLDER: credential aside, e.g. a security-review ticket that raised the count fleet-wide and the JDBC outage that followed the next driver upgrade. Confirm, replace, or cut.]
So. Leave it at 4096 for application roles with generated passwords; the iterations protect nobody there and every connection pays for them. If you have human-chosen passwords on roles that matter, the better fix is to stop having them (certificates, GSSAPI, or OAuth in 18), and the second-best is to raise this, in which case: pick a value you can defend that is not above 100,000, put it in postgresql.conf rather than a session, make sure whatever sets passwords (\password, createuser --pwprompt, your provisioning tooling) is built on libpq 16 or later so it stops minting 4096s, and then reset every password on the cluster before you call it done. Logging in works from any libpq that speaks SCRAM, at any count; it is only the setting of passwords that cares. A raised scram_iterations with a mix of old and new passwords behind it is not a stronger configuration. It is a weaker one with a tell.