node-postgres: "empty password returned by client"
ENGINEERING
AUGUST 31, 2026

node-postgres: "empty password returned by client"

5 min read
BACK TO BLOG

The error names the symptom and not one component that caused it. Object.assign let a parsed connection string overwrite the function minting the IAM token.

If you landed here from a search, the short version is at the bottom under The fix. The rest is why it took a while to find, which is the part I think is worth reading.

The setup

A service connecting to RDS Postgres with IAM authentication rather than a stored password. The appeal is obvious: no long-lived database credential anywhere in the system, no secret to rotate, no secret to leak. The token is minted on demand and expires in fifteen minutes.

In node-postgres you do that by giving the Pool a password that is a function rather than a string. The driver calls it for each new connection, and it returns a freshly signed token.

new Pool({
  host, port, database, user,
  password: async () => signer.getAuthToken(),
  ssl: { rejectUnauthorized: true },
})

That works. What did not work was the version I actually had, which built config from a connection string first and then layered the IAM bits on top.

The error

error: empty password returned by client

That is the whole message. It arrives on every connection attempt, immediately, in a service that had been working the week before.

Read it literally and it is accurate: the client returned an empty password. What it does not tell you is that a function was supposed to be there, that something replaced it, or where. It describes the last moment before failure and nothing about the cause.

Why is this hard to find?

I did what the message suggests, in roughly this order.

Is the token function throwing? Logged inside it. It never ran. That is the first genuinely useful signal, and it should have redirected me faster than it did — a function that is never called has usually been replaced, not broken.

Is the IAM policy wrong? Checked rds-db:connect, checked the resource ARN down to the database user. Fine. This was the expensive detour: an IAM problem and a config problem look identical from the application, and IAM is where you expect the trouble to be with a setup like this.

Is the token expiring? No — it was never being minted.

Is it SSL? RDS IAM auth requires TLS, and getting that wrong produces its own confusing errors. Not this one.

Somewhere in there I stopped debugging the database and started reading how the config object was assembled, which is where it was the whole time.

The cause

The config was built in two steps. Parse the connection string for host, port, database and user, then merge in the IAM-specific pieces:

const parsed = parse(process.env.DATABASE_URL)   // → { ..., password: '' }

const config = Object.assign(
  { password: async () => signer.getAuthToken() },
  parsed,                                         // ← this wins
)

Object.assign copies left to right, and later sources overwrite earlier ones. The parsed connection string carries a password key. There is no password in the URL, so its value is an empty string — and an empty string is a perfectly valid value to assign. It silently replaced the function.

No error, no warning, no type complaint. password had the right name and the wrong type, and nothing in the chain cared until Postgres was handed an empty string and said so.

The fix

Put the thing that must win last:

const config = {
  ...parse(process.env.DATABASE_URL),
  password: async () => signer.getAuthToken(),   // last, so it survives
}

And assert it, because the failure mode is silent and will recur the moment somebody adds another merge:

if (typeof config.password !== 'function') {
  throw new Error('IAM token function was overwritten during config assembly')
}

That throw is worth more than the fix. The fix stops today's bug; the assertion turns tomorrow's version of it into an error message that names the actual problem.

What I took from it

A credential that is a function is a different kind of thing from a credential that is a string, and most config plumbing was written assuming strings. Spreads, merges, Object.assign, defaults-then-overrides — all of it will cheerfully swap a function for an empty string, because at the type level nothing is wrong.

When a callback never fires, suspect replacement before failure. I lost the most time to IAM policy because that is where I expected a problem to be, not because the evidence pointed there. The evidence — a function that never ran — was pointing at assignment from the start.

Error messages describe the last moment, not the cause. "Empty password returned by client" is a true statement about the final microsecond of a chain of events, and no part of it is about Object.assign. Worth remembering when the message and the bug seem to have nothing to do with each other: that is normal, not a sign you are missing something clever.

This came out of a multi-tenant clinical platform I designed and built — 95 TypeScript source files, 29 test files, 36 migrations. It is built and owned by me, with no live client, so nobody's patients were affected by any of this. The architecture, the isolation tests and two other bugs of this shape are public:

github.com/jaklabs/telehealth-platform-reference

If you want to judge whether I am worth listening to before reading any further, the free website check is a public unauthenticated endpoint that takes an arbitrary URL from a stranger and drives headless Chromium at it, with the SSRF boundary that implies. Poke at it rather than take my word for anything.

And if you are shipping something where being wrong is expensive and you want another pair of eyes on the parts that fail quietly, that is what I do. Tell me what you built.

SHARE
5 min read
Read More

MORE ARTICLES

MSUFCU turned its own website off. That was the right call.
Engineering
Engineering

MSUFCU turned its own website off. That was the right call.

A PHP flaw became unauthenticated remote code execution with no workaround, so banks and governments went dark on purpose. What happened in Lansing, and why.

I gave an AI agent a wallet and a spending limit
Engineering
Engineering

I gave an AI agent a wallet and a spending limit

An autonomous client paid for its own API call in stablecoins. The payment was the easy half — the interesting half is the record of what it was refused.

The demo worked because you built it on your own data
Engineering
Engineering

The demo worked because you built it on your own data

The most expensive failure in applied AI is the one that looks like a success. Here is the test that catches it, and why it has to be able to fail.

How to read the arithmetic on a views guarantee
Engineering
Engineering

How to read the arithmetic on a views guarantee

A real offer promises 10 million views in 90 days or you do not pay. Here is the division that tells you what you are actually buying.