brianc/node-postgres · critical · Error

SASL: SCRAM-SERVER-FIRST-MESSAGE: salt must be base64

Error message

SASL: SCRAM-SERVER-FIRST-MESSAGE: salt must be base64

What it means

Thrown by parseServerFirstMessage during SCRAM-SHA-256 authentication when the salt attribute (`s=`) from the server's first SASL message fails the base64 format check at sasl.js:204 (regex isBase64 at line 172). A compliant PostgreSQL server always emits a valid base64 salt, so this almost always indicates a corrupted or non-conformant server response rather than a client misconfiguration.

Source

Thrown at packages/pg/lib/crypto/sasl.js:205

      return [name, value]
    })
  )
}

function parseServerFirstMessage(data) {
  const attrPairs = parseAttributePairs(data)

  const nonce = attrPairs.get('r')
  if (!nonce) {
    throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: nonce missing')
  } else if (!isPrintableChars(nonce)) {
    throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: nonce must only contain printable characters')
  }
  const salt = attrPairs.get('s')
  if (!salt) {
    throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: salt missing')
  } else if (!isBase64(salt)) {
    throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: salt must be base64')
  }
  const iterationText = attrPairs.get('i')
  if (!iterationText) {
    throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: iteration missing')
  } else if (!/^[1-9][0-9]*$/.test(iterationText)) {
    throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: invalid iteration count')
  }
  const iteration = parseInt(iterationText, 10)

  return {
    nonce,
    salt,
    iteration,
  }
}

function parseServerFinalMessage(serverData) {
  const attrPairs = parseAttributePairs(serverData)

View on GitHub (pinned to c5e8c9a57b)

Solutions

  1. Connect directly to the PostgreSQL primary (bypass the pooler/proxy) to isolate the mangling source.
  2. Upgrade PgBouncer to >=1.10 (proper SCRAM-SHA-256 support) and confirm auth_type is not a legacy method re-deriving verifiers.
  3. Verify the host/port in the connection string actually point at a PostgreSQL server, not a proxy.
  4. If TLS is terminated by a proxy, terminate TLS directly at Postgres so SASL frames pass through unmodified.

Example fix

// before
const client = new Client({ host: 'pgbouncer.internal', port: 6432 })
// after
const client = new Client({ host: 'postgres-primary.internal', port: 5432 })
Defensive patterns

Strategy: try-catch

Try / catch

try {
  await client.connect()
} catch (err) {
  if (/salt must be base64/.test(err.message)) {
    // server SASL frame was malformed -- suspect a proxy/pooler
    logger.error('SCRAM frame corruption from intermediary', { err })
    return connectBypassingPooler()
  }
  throw err
}

Prevention

When it happens

Trigger: Reached when continueSession (sasl.js:62) is invoked during client.connect() and the parsed `s=` value contains characters outside [A-Za-z0-9+/] or has incorrect `=` padding. The salt is then unusable for the PBKDF2 key derivation at line 116.

Common situations: PgBouncer or another pooler/proxy in transaction-pooling mode that does not relay SCRAM frames correctly; a TLS-terminating reverse proxy that rewrites or truncates the auth exchange; connecting to a non-PostgreSQL server speaking a SASL/SCRAM variant with a different encoding.

Related errors


AI-assisted analysis of brianc/node-postgres@c5e8c9a57b (2026-08-03). Data as JSON: /data/errors/6858bd8e95450e54.json. Report an issue: GitHub.