{"id":"4c91f49b1e897788","repo":"brianc/node-postgres","slug":"sasl-scram-server-final-message-server-returned","errorCode":null,"errorMessage":"SASL: SCRAM-SERVER-FINAL-MESSAGE: server returned error: \"${error}\"","messagePattern":"SASL: SCRAM-SERVER-FINAL-MESSAGE: server returned error: \"(.+?)\"","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"critical","filePath":"packages/pg/lib/crypto/sasl.js","lineNumber":228,"sourceCode":"  } else if (!/^[1-9][0-9]*$/.test(iterationText)) {\n    throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: invalid iteration count')\n  }\n  const iteration = parseInt(iterationText, 10)\n\n  return {\n    nonce,\n    salt,\n    iteration,\n  }\n}\n\nfunction parseServerFinalMessage(serverData) {\n  const attrPairs = parseAttributePairs(serverData)\n  const error = attrPairs.get('e')\n  const serverSignature = attrPairs.get('v')\n\n  if (error) {\n    throw new Error(`SASL: SCRAM-SERVER-FINAL-MESSAGE: server returned error: \"${error}\"`)\n  }\n\n  if (!serverSignature) {\n    throw new Error('SASL: SCRAM-SERVER-FINAL-MESSAGE: server signature is missing')\n  } else if (!isBase64(serverSignature)) {\n    throw new Error('SASL: SCRAM-SERVER-FINAL-MESSAGE: server signature must be base64')\n  }\n  return {\n    serverSignature,\n  }\n}\n\nfunction xorBuffers(a, b) {\n  if (!Buffer.isBuffer(a)) {\n    throw new TypeError('first argument must be a Buffer')\n  }\n  if (!Buffer.isBuffer(b)) {\n    throw new TypeError('second argument must be a Buffer')","sourceCodeStart":210,"sourceCodeEnd":246,"githubUrl":"https://github.com/brianc/node-postgres/blob/c5e8c9a57bff6d9160ec5dbd5c4f4c1e4c460711/packages/pg/lib/crypto/sasl.js#L210-L246","documentation":"Thrown by parseServerFinalMessage when the SCRAM final message contains an `e=` attribute (sasl.js:227-228) — the server's formal error indicator per RFC 5802. Common values: `invalid-proof` (bad password), `channel-binding-not-supported`, `other-error`. Unlike the parse errors, this is *expected* server behavior and usually denotes a real authentication failure rather than frame corruption.","triggerScenarios":"finalizeSession (sasl.js:129) is invoked after the client sends its proof, and the server's final frame contains `e=...` instead of (or alongside) the `v=` verifier. attrPairs.get('e') at line 224 is truthy.","commonSituations":"Wrong password; password rotated since a connection pool was built; role dropped; SCRAM verifier corrupted server-side; channel-binding negotiation mismatch (server demands binding the client cannot provide).","solutions":["Verify the password/credential is current and correct against the actual role.","Confirm the PostgreSQL role exists: SELECT rolname FROM pg_roles WHERE rolname = $1.","For `channel-binding-not-supported`, retry over plain SCRAM-SHA-256 (drop channel binding or fix TLS).","Regenerate the role password if the stored verifier may be corrupted."],"exampleFix":"// before\nconst client = new Client({ user: 'app', password: process.env.PGPASSWORD })\n// after -- reload the credential from a live secret source\nconst client = new Client({ user: 'app', password: await secrets.fetch('pg/app') })","handlingStrategy":"try-catch","validationCode":null,"typeGuard":null,"tryCatchPattern":"try {\n  await client.connect()\n} catch (err) {\n  const m = err.message.match(/server returned error: \"([^\"]+)\"/)\n  if (m) {\n    const code = m[1]\n    if (code === 'invalid-proof') throw new AuthError('bad credentials')\n    if (code === 'channel-binding-not-supported') return connectWithoutChannelBinding()\n    throw new AuthError(`scram failure: ${code}`)\n  }\n  throw err\n}","preventionTips":["Pull credentials from a live secret manager rather than a cached env var.","Test role existence and password validity in CI before deploy.","Only enable channel binding when both client and server advertise it cleanly."],"tags":["sasl","scram","authentication","credentials","password"],"analyzedSha":"c5e8c9a57bff6d9160ec5dbd5c4f4c1e4c460711","analyzedAt":"2026-08-03T18:47:28.334Z","schemaVersion":2}