PostgreSQL's scram_iterations hides a security trap in migrations

- Reading
- 7 min
- Code
- 2
The parameter arrived in PostgreSQL 16 to make the SCRAM-SHA-256 iteration count configurable, but the fix for the flaw it opened only moved the problem, without closing it.
Consultant Christophe Pettus, of PGX Inc., published another chapter of the "All Your GUCs in a Row" series on Planet PostgreSQL, this time about scram_iterations. The name suggests a server-side tuning knob, but it isn't one: the parameter works as the default value applied at the moment a password is set. After that, the number is stored inside the password itself, not in the database configuration, and that detail explains the rest of the GUC's behavior.
What the parameter actually controls
Its context is user (any role can change it with SET), the default is 4096, and the range goes from 1 to 2147483647. It arrived in PostgreSQL 16 (2023); before that, the number was a fixed constant in the source code, also 4096, following the minimum recommendation of RFC 7677 (2015). The server only reads this value at two moments: when someone sets a new password, and when someone tries to log in as a role with no SCRAM password to check against.
How the number becomes part of the password
The value 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 used in the authentication exchange. The hash result itself is not stored. What stays in pg_authid.rolpassword is the string SCRAM-SHA-256$4096:<salt>$<StoredKey>:<ServerKey>, with the count embedded right there.
This means that changing scram_iterations and reloading the configuration does not alter any existing password, because each one already carries its own count. pg_dumpall -r reproduces the string literally, so the number survives a restore, a pg_upgrade, and even a copy into PgBouncer's auth_file. The only way to migrate a password to a new count is to reset it, which requires knowing the password, something the server does not keep.
Who pays for the iterations
Here is the point that matters to whoever operates the application, not just the DBA: in scram-sha-256 authentication, the server barely does any work. Pettus measured ten connections each for roles with passwords set at 1, 4096, 100 thousand, and 1 million iterations. The backend used about 50 ms of CPU total across the ten connections, in every scenario. The client, in turn, spent 0.05, 0.14, 1.9, and 18.3 seconds, respectively.
In pgbench -C, which reconnects on every transaction, the default (4096) sustained 75 connections per second versus 264 with 1 iteration. In PostgreSQL 18, with log_connections = 'setup_durations', the connection-ready log line for the one-million-iteration role recorded authentication=1802.897 ms: that's the server itself accounting for the time it spent waiting for the client to finish computing.
The password method in pg_hba.conf is the exception, because there the client sends the password in plain text and the server does the derivation on every login: 1.7 seconds of backend CPU per connection at one million iterations, paid by the server on every attempt, including failed ones. As Pettus summarizes, that method was already a bad idea even before scram_iterations existed.
What the iteration count protects, and what it doesn't
The real benefit is resistance to offline brute-force attacks if pg_authid leaks, for instance in a backup forgotten in the wrong bucket or a replica that left the datacenter room. Whoever has the string cannot log in with it directly, because StoredKey is a hash of the key the client must prove it knows; all that's left is to try passwords, and each attempt costs the full iteration count.
This does nothing against machine-generated 32-character passwords: they aren't guessable even with a single iteration, so raising the count for service roles charges every connection without buying any security. And RFC 5802 itself points out that the stored string plus an eavesdropped exchange, or a fake server, lets an attacker impersonate the user without ever learning the password. What solves that is sslmode=verify-full or channel_binding=require, not the iteration count.
The CVE the fix didn't close for good
This is the central point for anyone thinking about changing the value for security reasons. To avoid revealing which usernames exist, the server needs to respond with a plausible salt and iteration count even for a role that doesn't exist, in a "mock exchange." Since PostgreSQL 10, this has worked with a salt derived from the username and a cluster nonce, plus an iteration count.
Until August 2026, this mock count was always the constant 4096, which wasn't a problem until PostgreSQL 16 made the real value configurable. From then on, setting scram_iterations = 100000 and creating roles made every real role respond with i=100000, while every phantom role responded with i=4096: a user-existence oracle, cataloged as CVE-2026-14672, reported by Radim Marek and fixed by Nathan Bossart in versions 18.6, 17.11, and 16.15.
The problem is that the fix doesn't close the oracle, it only shifts it. Pettus ran a probe before and after the patch:
--- 18.4, postgresql.conf scram_iterations = 100000
u100k: i=100000
u4096: i=4096
no_such_role: i=4096
--- 18.6, mesma configuração
u100k: i=100000
u4096: i=4096
no_such_role: i=100000On the patched server, the phantoms now match roles created after the value was raised, and it's the roles created before it that stand out, giving away passwords still on the old count. As Pettus writes:
Read the second block again. The fix did not close the oracle; it moved it.
Christophe Pettus, consultant at PGX Inc.
The only real cure is to bring every password in the cluster to the same count as postgresql.conf, and a prior inventory helps gauge the size of the problem:
select coalesce(substring(rolpassword from '^SCRAM-SHA-256\$(\d+):'),
left(rolpassword, 3), '(none)') as password_type,
count(*)
from pg_authid
group by 1 order by 2 desc, 1;And since the parameter's context is user, there's no way to enforce this fix from the top down: any role authorized to change its own password can run SET scram_iterations = 1 before \password, and the server has no minimum floor to prevent that.
The reverse risk: hostile server, trapped client
Since it's the server that announces the count and the client that does the heavy lifting, a compromised or malicious server can announce a gigantic number and trap the client in PBKDF2 for as long as it wants. RFC 5802 already warned about this, and, as of libpq 18.6 and the 19 release candidate, connect_timeout does not interrupt that loop: Pettus tested a psql with connect_timeout=3 against a server simulating twenty million iterations, and the client burned 39 seconds of CPU before reporting that the timeout had expired.
At the GUC's maximum value, this would take over an hour on the same machine, and a real server would already have given up on the connection due to authentication_timeout (60 seconds) without the client noticing, because no one was listening on the other end anymore. Sehrope Sarkuni proposed, in May 2026, a set of patches on the hackers mailing list to make the loop respect connect_timeout and add a ceiling of 100,000 on the client side itself; Daniel Gustafsson supported the timeout fix but opposed creating yet another connection parameter.
Other drivers have already adopted this ceiling on their own. pgjdbc 42.7.11 (April 2026, CVE-2026-42198) and node-postgres 8.21 refuse any count above 100,000 unless scramMaxIterations is raised manually; PgBouncer 1.26.0 (September 2026, CVE-2026-6669) flatly refuses any value above 1,000,000 coming from the server. In practice: raising scram_iterations above 100 thousand and resetting passwords makes Java and Node applications stop connecting with that role, with an error from the driver itself, with no apparent connection to the server's configuration.
Before changing this value
Pettus's recommendation, and the sensible starting point for any team running PostgreSQL in production, can be summarized in four decisions:
- For application roles with randomly generated passwords, keep the default of 4096: the iterations don't protect anything there, and every connection pays the price for nothing.
- For roles with a human-chosen password, the ideal is to eliminate the password altogether: certificates, GSSAPI, or OAuth (available since PostgreSQL 18).
- If the decision really is to raise the value, choose a defensible number no higher than 100,000 (the ceiling the major drivers already impose), set it in
postgresql.conf, never at the session level, and make sure every tool that sets passwords (\password,createuser --pwprompt, automated provisioning) runs on libpq 16 or newer, so it doesn't silently write a 4096. - After that, reset every password in the cluster before considering the task done, using the inventory in
pg_authidto confirm it.
As Pettus closes:
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.
Christophe Pettus, consultant at PGX Inc.
Translated from the Brazilian Portuguese original · Read the original
PostgreSQL and NUMA in the cloud: why huge pages and CAP_SYS_NICE decide performance
A four-part series by consultant Chris Travers, of Stormatics, shows how NUMA architecture still affects PostgreSQL even inside VMs and containers, and why ignoring it costs latency in production.





