Cloudflare build: a failed TLS handshake (untrusted CA, e.g. Supabase) surfaces as CONNECT_TIMEOUT instead of the real error
Nobody has claimed this yet.
- Dominant language
- JavaScript
- Stars
- 8.7k
- Forks
- 374
- Avg merge
- 11d 16h
- Merged PRs (30d)
- 1
Description
Version
postgres@3.4.9, Cloudflare Workers build (cf/src/index.js + cf/polyfills.js), nodejs_compat, compatibility date 2025-10-01.
Summary
When the server's certificate is not trusted by the Workers runtime (private CA), ssl: 'require' and ssl: 'prefer' both fail with CONNECT_TIMEOUT after connect_timeout seconds, with no hint that TLS is the cause. The same host connects and queries fine with ssl: false, and raw cloudflare:sockets open to it in ~100 ms, so the pooler, the network and SCRAM auth are all fine: only the TLS step is broken, and the error is lost.
Target: Supabase's pooler (*.pooler.supabase.com:6543, Supavisor) and direct host (db.<ref>.supabase.co:5432). Both present a chain issued by the private "Supabase Root 2021 CA" (openssl s_client -starttls postgres → Verify return code: 19 (self-signed certificate in certificate chain)).
What actually happens at the socket level
Replaying the same choreography by hand on cloudflare:sockets (SSLRequest → 'S' → startTls() → StartupMessage → first reply), from the same Worker:
| variant | result |
|---|---|
secureTransport: 'starttls', then startTls() |
TCP open at 102 ms, 'S' at 194 ms, then Error: TLS Handshake Failed. on the first write/read of the upgraded socket |
secureTransport: 'on' (TLS-first) |
proxy request failed, cannot connect to the specified address (Supavisor does not speak direct TLS either way) |
| plain, no TLS | R AuthenticationSASL SCRAM-SHA-256 at 183 ms |
Workers TCP sockets always validate the server certificate and have no rejectUnauthorized/custom-CA option (cloudflare/workers-sdk#3366; node:tls on workerd throws The options.rejectUnauthorized option is not implemented). So on Workers, ssl: 'require'/'prefer'/'allow' cannot do what they do in Node (rejectUnauthorized: false): the handshake is rejected by the runtime.
That part is a platform limitation, not a postgres.js bug. The bug is that the failure never reaches the caller.
Where the error is lost (cf/polyfills.js)
In tls.connect:
tcp.raw = tcp.raw.startTls({ servername })
tcp.raw.closed.then(
() => tcp.emit('close'),
(e) => tcp.emit('error', e)
)
tcp.writer = tcp.raw.writable.getWriter()
tcp.reader = tcp.raw.readable.getReader()
tcp.writer.ready.then(() => {
tcp.read()
tcp.readyState = 'upgrade'
})
return tcp
and secureConnect is only emitted from the original socket's closed handler (in connect()), when readyState === 'upgrade':
tcp.raw.closed.then(
() => {
tcp.readyState !== 'upgrade'
? close()
: ((tcp.readyState = 'open'), tcp.emit('secureConnect'))
},
(e) => tcp.emit('error', e)
)
When the handshake fails, tcp.writer.ready never resolves (or rejects, with no rejection handler on that .then), readyState stays 'upgrading', secureConnect never fires, nothing is ever written or read on the upgraded socket, and the only thing that eventually happens is connectTimedOut() in src/connection.js. Observed behaviour: 5 s of silence then CONNECT_TIMEOUT, for both 'require' and 'prefer' (with 'prefer' the fallback to plaintext cannot trigger either, since the server did answer 'S').
For comparison, pg + pg-cloudflare on the same Worker and host surfaces Connection terminated unexpectedly immediately, which at least points at the transport.
Suggestion
- In the polyfill's
tls.connect, handle the rejection oftcp.writer.ready(and of the upgraded socket'sclosed) by routing it toerror(err), so the caller gets the runtime'sTLS Handshake Failed.(or the certificate error) instead of a timeout. - A note in the Cloudflare section of the README: on Workers the certificate is always verified by the runtime,
ssl: 'require'/'prefer'cannot skip verification, and a server with a private CA needs Hyperdrive (which accepts a custom CA) or a publicly trusted certificate.
Repro
import postgres from 'postgres' // Workers build
const sql = postgres(process.env.SUPABASE_POOLER_URL, {
ssl: 'require', prepare: false, max: 1, connect_timeout: 5,
})
await sql`select 1` // → CONNECT_TIMEOUT after 5 s ; with ssl: false → ok
Happy to open a PR for (1) if that direction sounds right.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in cf/polyfills.js at tls.connect and compare its error handling with the original socket flow in connect(); then inspect src/connection.js around connectTimedOut(). Reproduce the Cloudflare Workers case with ssl: 'require' against the Supabase endpoint, and consider the work complete when the TLS failure reaches the caller instead of becoming CONNECT_TIMEOUT and the Cloudflare README explains the certificate limitation.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, postgresql, supabase
- Domain
- backend, cloud, database
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 70/100