Get started

Engineering NotesDatabase connections and OwlDB

Supabase connection addresses are filled by OAuth approval, not copied by hand

The Supabase dashboard lists three addresses for the same database: the project's direct address, port 6543 on the shared pooler (the connection pooler Supabase runs in front of each project) in transaction mode, and port 5432 on the same pooler in session mode. OwlAI uses only session-mode 5432. The username has to end in .<project ref>, and the server certificate has to be verified against the Supabase root CA, which is not in the system trust store. When an administrator copies an address by hand and picks 6543, OwlAI's PostgreSQL executor, which sends SQL as prepared statements, does not run. A bare role name gets the connection rejected by the pooler, and turning on TLS verification makes certificate validation fail.

So on OwlAI SaaS, administrators do not copy addresses. They click Connect with Supabase and approve OwlAI in Supabase, and OwlAI reads the chosen project's pooler coordinates through the Supabase Management API and fills the registration form. That approval is used for this one fill. So OwlAI holds the access token for only an hour, and the callback, which returns without a login, finishes only in the browser that started the approval. This post covers the coordinates we fill, how we hold the approval briefly, and how we tie the callback back to the person who approved. The OAuth app is the single app OwlAI registered with Supabase, so the feature exists only on OwlAI SaaS.

Of the three addresses, session-mode 5432 is the one that works

When the administrator picks a project, the server reads that project's pooler configuration and fills the form with the pooler host and database name plus session-mode port 5432. Here is how the three addresses compare (Supabase documentation, checked 2026-11-03).

Address In OwlAI
Direct address IPv6 by default, so it is unreachable from IPv4-only networks. Supabase's documentation also recommends the session-mode pooler for IPv4 networks
Transaction mode, 6543 Meant for serverless and edge functions, and does not accept prepared statements. OwlAI's PostgreSQL executor runs SQL as prepared statements
Session mode, 5432 Reachable over IPv4, and prepared statements work as is. This is what we fill in

The pooler response also carries a port and a pool mode, but we ignore them. They describe the project's transaction-mode setting, and following them would yield 6543. If the response includes poolers for read replicas, we pick the primary database's.

The username field stays empty. The default account Supabase reports, postgres.<ref>, is an admin account with write privileges, so the account the AI queries with is a read-only role the administrator created. The administrator enters only the role name. At registration the server appends .<ref> to it. If the same ref is already there, it is left alone; if another project's ref is attached, the request is rejected with SUPABASE_USERNAME_PROJECT_MISMATCH. The Management API does not return database passwords, so the administrator enters the password.

TLS is required, and both the certificate chain and the hostname are verified (libpq sslmode=verify-full). Verification uses the Supabase root CA bundled with the product (expiring April 2031). The rule that a connection carrying its own CA trusts only that CA is covered in “Certificate errors are fixed with the right CA and the right name, not by turning off verification”. This connection does not go through the installation's trust store, so changing the internal CA bundle does not change how it is verified.

The access token from the approval is held for one hour

When authorization starts, the server creates one import session row. It holds all state from waiting for approval through picking a project, and it lives in two phases.

Phase What the row holds Lifetime
Awaiting approval state, the PKCE code verifier, a hash of the browser binding cookie 10 minutes
Ready the Supabase access token 1 hour

The authorization URL carries the state and an S256 code challenge. To exchange the authorization code, the server sends the verifier together with the OAuth app's client secret. Once the exchange succeeds, the same row moves to the ready phase, and the access token takes the verifier's place. The refresh token is discarded. All the access token does is read the organization and project lists and the pooler coordinates, and that work ends within the hour the administrator spends at the screen.

The verifier and the access token are stored with envelope encryption, with the purpose, organization and row ID in the encryption context. Copy the ciphertext to another row and it will not decrypt. A row that will not decrypt is treated exactly like a missing row.

Expiry times are written and compared with the database's now(), so expiry is judged the same even when backend replicas disagree on host time.

The read paths delete expired rows as they reject them. A row left by someone who approved and never came back is never read, and its token may still be valid on the Supabase side. A cleanup job that runs every hour deletes those rows.

A successful registration does not end the session. So the administrator can register other projects from the same Supabase account, the screen shows Register another project until the session expires, and Finish deletes the session immediately.

The callback, which returns without a login, finishes only in the browser that started the approval

The callback from Supabase is a top-level browser GET, so it carries no OwlAI login token. The server finds the row by state and reads and writes only within that row's organization. It locks the row, reads the state and clears it, so each state is used once.

There is one attack state cannot stop. An attacker starts authorization in their own OwlAI organization and gets a victim to open the resulting authorization URL. If the victim, already signed in to Supabase, approves, the callback comes back normally and the victim's Supabase token lands in the session the attacker started. For an hour, the attacker can read the victim's project list and pooler addresses. PKCE does not stop this either, because the server holds the verifier and performs the exchange itself.

So the response that starts authorization sets a one-time binding cookie in that browser, and the row stores only a hash of the cookie's value. The cookie is HttpOnly and SameSite=Lax. Lax means the cookie still rides along on the top-level navigation back from Supabase. The callback compares the cookie's hash with the row. If the cookie is missing or different, it deletes the row without exchanging the authorization code and ends with SUPABASE_IMPORT_BROWSER_MISMATCH. The victim's browser, returning from a URL the attacker handed over, does not have that cookie.

When you click Connect with Supabase in the desktop app, everything from approval to registration happens in the web browser. The callback returns to the system browser, and the binding cookie exists only where authorization started.

The callback sends the result back to the database admin screen in the URL fragment (the part after #): #supabaseImport=<session ID> or #supabaseImportError=<errorCode>. In the query, the session ID would land in the server's access log and in the Referer of the next request; the browser never sends the fragment to the server. The screen reads the fragment on first render and immediately removes it from the address bar. Even on an unexpected exception, the callback sends SUPABASE_UNAVAILABLE to the same screen, so the administrator sees a button to start over instead of a blank error page.

Only the administrator who started the session can call the APIs that read it (project list, form prefill, session delete). Another administrator in the same organization gets the same response as for a missing session even with the session ID, and rows from other organizations are hidden by row-level security.

Only the callback from the browser holding the binding cookie exchanges the code
Figure 1. Only the callback from the browser holding the binding cookie exchanges the code

At registration, we ask Supabase for the coordinates once more

Add in the form calls the same registration API as a hand-filled direct connection. When the request carries the import session ID and the project ref, the server uses that session to ask Supabase for the pooler coordinates again. If the request's engine, host or port differ from Supabase's answer, it rejects the request with 422 SUPABASE_IMPORT_TARGET_MISMATCH, and the server sets TLS to required with the bundled CA regardless of the request. A connection whose source is Supabase can therefore only point at the server Supabase reports right now.

After that, the connection target check, the per-minute connection attempt limit, actually connecting to read the table list, and saving in one transaction all run exactly as they do for a direct connection, and if the connection fails, nothing is saved. Picking from a list, prefilling the form and saving through the existing registration API is the same structure the Amazon RDS import uses (“Customer RDS lists are read with a 60-minute role, not an access key”).

The saved connection's source is fixed as Supabase, with the project ref stored alongside. Per-source rules come from a single connection source definition (“Two SQL executors, one set of checks and one result format”). A Supabase source connects only through the public pooler, so it does not accept an SSH tunnel. Edits cannot change the engine, host, port or TLS (DB_CONNECTION_SOURCE_LOCKED); the only way to change them is Import again.

Design choices

We chose OAuth approval over having administrators paste a Supabase personal access token. Pasting needs no callback and no session row, so it is simpler, but OwlAI would have to hold that token until the administrator deletes it. Filling a form once does not call for a permission that lasts that long.

We discard the refresh token. A refreshable token would let one approval last a long time, but this feature is done once the form is filled. The cost is that after an hour the administrator has to approve again, and the approval cannot be revoked from OwlAI's screens; revocation happens in the Supabase account's list of authorized apps.

We bind the callback to the browser that started authorization. An authorization URL handed over by someone else cannot create a session. The cost is that if the cookie was cleared, or the callback opens in a different browser, authorization starts over.

We fix the pooler port at 5432 instead of turning off prepared statements in the driver and using 6543. Transaction mode lets many clients share one server connection, which leaves headroom on connection count. But OwlAI's PostgreSQL executor runs SQL as prepared statements on every PostgreSQL connection, and in session mode that works as is. Connections already saved with 6543 are not migrated automatically, and because the edit lock compares against the saved server, their other fields can still be edited.

We ask Supabase for the coordinates again at registration. Trusting the values the screen filled would save calls, but a connection whose source says Supabase could then point at a server Supabase does not know. The cost is two more reads against Supabase per registration, and when Supabase does not respond, a wait of up to about 41 seconds before the retry limit runs out.

We ship the Supabase root CA with the product. No administrator has to find and paste a CA file, but a release carrying the new CA has to ship before April 2031. Starting a year before expiry, a warning is logged at boot.

The same conditions apply to any feature that reads values once from an external service's console to fill a form. Hold the token only as long as filling takes, and verify a callback that returns without a login with both state and a cookie from the browser that started the approval.