The short answer
Self-signed certificate in certificate chain means the client reached Supabase over TLS, but it could not build a trusted chain to Supabase’s private root certificate. The fix is to supply prod-ca-2021.crt, downloaded from Supabase’s Database settings, and keep certificate verification on.
In node-postgres, pass the CA and leave rejectUnauthorized at true. Do not remove verification with rejectUnauthorized: false, NODE_TLS_REJECT_UNAUTHORIZED=0, or uselibpqcompat=true. Those make the error disappear by trusting any certificate.
If you also need the host, port, and username rules, see the Supabase connection string guide’s SSL section. To inspect a URI before connecting, use the connection string checker.
What the error means
OpenSSL reports self-signed certificate in certificate chain when it cannot find a trusted path from the server certificate to a root in its trust store. The leaf certificate is not necessarily self-signed. In this case, the chain ends at Supabase Root 2021 CA, a private root that Node does not ship.
The server did present a certificate and the TLS handshake started. The client refused the certificate before any SQL ran. This is different from password authentication failed, Tenant or user not found, or a DNS ENOTFOUND.
Node’s error code is usually SELF_SIGNED_CERT_IN_CHAIN. The message and code come from the TLS layer, not from PostgreSQL.
Why Supabase triggers it
Supabase signs database certificates with its own root. The certificate is not in the public CA bundle used by Node, browsers, or your operating system. Supabase’s connection docs tell you to download the server root certificate from Database settings and point your driver at it.
Supabase also describes sslmode=require as encryption without server verification. That is the libpq behavior. It is not what node-postgres 8.22 does.
With pg@8.22.0 and pg-connection-string@2.14.0, the parser treats prefer, require, and verify-ca as aliases for verify-full unless you set uselibpqcompat=true. It prints this warning:
SECURITY WARNING: The SSL modes 'prefer', 'require', and 'verify-ca' are treated as aliases for 'verify-full'.
So adding ?sslmode=require does not make the Supabase error go away in node-postgres 8.22. It actually turns certificate verification on. Without Supabase’s CA, the result is self-signed certificate in certificate chain.
Fix for node-postgres
Download prod-ca-2021.crt from Supabase Database settings, keep it outside source control if your deployment policy requires that, and load it into the client:
import fs from "node:fs";
import { Client } from "pg";
const client = new Client({
connectionString:
"postgresql://postgres.[ref]:[password]@aws-[n]-[region].pooler.supabase.com:5432/postgres",
ssl: {
ca: fs.readFileSync("./prod-ca-2021.crt", "utf8"),
rejectUnauthorized: true,
},
});
await client.connect();
await client.query("select 1");
await client.end();
Keep sslmode and sslrootcert out of that connectionString. If either appears in the URI, node-postgres replaces the separate ssl object and your ca is lost. The node-postgres SSL docs state this directly.
If you prefer to keep everything in the URI, use:
postgresql://postgres.[ref]:[password]@aws-[n]-[region].pooler.supabase.com:5432/postgres?sslmode=verify-full&sslrootcert=./prod-ca-2021.crt
verify-full checks both the certificate chain and the hostname. pg-connection-string reads sslrootcert into ssl.ca.
Do not disable verification
rejectUnauthorized: false, NODE_TLS_REJECT_UNAUTHORIZED=0, and uselibpqcompat=true&sslmode=require all accept the certificate without proving it belongs to Supabase. They are not fixes for a production connection.
Fix for Prisma 6
Prisma 6’s built-in PostgreSQL engine supports only sslmode=prefer, disable, or require. It does not support sslmode=verify-full. For a root CA, use sslcert and set sslaccept=strict:
DATABASE_URL="postgresql://postgres.[ref]:[password]@aws-[n]-[region].pooler.supabase.com:5432/postgres?sslmode=require&sslcert=../prod-ca-2021.crt&sslaccept=strict"
Prisma resolves certificate paths relative to the ./prisma directory. If the certificate sits in the project root, use ../prod-ca-2021.crt. With a multi-file schema, paths resolve relative to ./prisma/schema instead. The Prisma 6 data source docs give both rules.
Prisma 6’s built-in engine reads the CA from sslcert; its URL parser does not read sslrootcert. sslaccept=strict rejects a certificate that is missing a required value, such as the hostname. The default, accept_invalid_certs, bypasses that check.
Driver adapter note: this applies to Prisma 6’s built-in engine; if you use Prisma 6 with @prisma/adapter-pg, SSL is handled by node-postgres, so follow the node-postgres section instead — there, sslcert means a client certificate and the CA goes in ssl.ca or sslrootcert (pg-connection-string source).
Fix for Prisma 7
Prisma 7 uses the pg driver adapter. Its documented fix is to add Supabase’s CA to Node’s trust store before the process starts:
NODE_EXTRA_CA_CERTS=./prod-ca-2021.crt node server.js
The Prisma 7 upgrade guide documents NODE_EXTRA_CA_CERTS for this exact certificate-validation change. Set the variable in the runtime environment, not after the process has started.
The adapter also passes its options to pg.Pool, so the node-postgres form works too:
import fs from "node:fs";
import { PrismaClient } from "../prisma/generated/client";
import { PrismaPg } from "@prisma/adapter-pg";
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL,
ssl: {
ca: fs.readFileSync("./prod-ca-2021.crt", "utf8"),
rejectUnauthorized: true,
},
});
export const prisma = new PrismaClient({ adapter });
Again, keep sslmode and sslrootcert out of DATABASE_URL when passing this ssl object. Prisma’s guide shows rejectUnauthorized: false as a compatibility escape hatch. Do not use it for a Supabase production connection.
Fix for psql
psql "postgresql://postgres.[ref]:[password]@aws-[n]-[region].pooler.supabase.com:5432/postgres?sslmode=verify-full&sslrootcert=./prod-ca-2021.crt" -c "select 1"
libpq’s verify-full checks the chain and hostname. require only encrypts, so it is not enough when you want to prove the server is Supabase.
Verify the fix
- Check the certificate fingerprint. Run
openssl x509 -in prod-ca-2021.crt -noout -fingerprint -sha256. The Supabase root used in this test has SHA-256 fingerprint80:70:25:AD:50:D4:ED:21:9D:2C:9C:7D:29:9C:00:4F:82:4E:B0:0C:F7:F6:5A:FE:F6:07:D0:7B:72:E6:CA:FA. - Remove SSL parameters from the URI if you are passing a separate
sslobject. Check: the string contains nosslmode,sslrootcert,sslcert, orsslkey. - Connect and run
select 1. Check: the query returns. If the connection now fails withTenant or user not found, TLS succeeded and the remaining problem is the host or username. - Confirm verification is still on. Check: no
rejectUnauthorized: false, noNODE_TLS_REJECT_UNAUTHORIZED=0, and nouselibpqcompat=true.
If it still fails
- Your
sslobject was replaced. A URI containingsslmodeorsslrootcertoverrides it. Remove those parameters or use the URI form. - Prisma cannot find the file. Paths resolve from
./prisma, or./prisma/schemafor multi-file schemas. Move the path one level if needed. NODE_EXTRA_CA_CERTSwas set too late. Node reads it at startup. Set it in the service definition or before thenodecommand.- The hostname does not match. Use the hostname from the same Supabase Connect string, including the correct pooler cluster index.
- A different client rejects the chain. Some strict X.509 clients reject the shared pooler’s intermediate CA for missing Key Usage. That is a known Supabase chain issue, separate from the root CA path.
Tested on
Reproduced 2026-10-26 against Supabase’s shared session pooler at aws-0-ap-northeast-1.pooler.supabase.com:5432. The project ref was the fake 20-character value aaaaaaaaaaaaaaaaaaaa, and the password was a placeholder, so the test did not depend on a real project or credentials.
-
Attempt 1, no CA:
ssl: { rejectUnauthorized: true }with no SSL parameters in the URI returnedself-signed certificate in certificate chainwith codeSELF_SIGNED_CERT_IN_CHAIN. -
Attempt 2, Supabase CA: the same connection plus
ca: fs.readFileSync("prod-ca-2021.crt", "utf8")completed TLS, then the pooler returned(ENOTFOUND) tenant/user postgres.aaaaaaaaaaaaaaaaaaaa not foundwith severityFATAL. That is the expected fake-ref result and confirms the certificate chain was accepted.
Runtime: Node v22.23.3, OpenSSL 3.5.8, pg@8.22.0, pg-connection-string@2.14.0. The direct host, transaction pooler, Prisma runtime, psql runtime, Windows, and macOS were not tested. Those fixes come from the documentation linked below.
Sources
- Supabase: Connect to your database, SSL
- Supabase: Postgres SSL enforcement
- node-postgres: SSL
- pg-connection-string 2.14.0 source
- Node.js: TLS options
- Node.js:
NODE_EXTRA_CA_CERTS - OpenSSL: verification options
- Prisma 6: PostgreSQL SSL parameters
- Prisma 6.19.0: PostgreSQL URL parser
- Prisma 6: certificate path resolution
- Prisma 7: SSL certificate validation changes
- Prisma 7 adapter-pg source
- PostgreSQL: libpq SSL options
- Supabase issue 50862: shared pooler strict TLS chain