跳到正文
DEV Community· Tomo·· 2 小時前AI 評分55

如何在 Supabase 強制啟用 SSL、量度重啟時間並建立加密狀態頁

Enforce SSL on Supabase, Then Measure the Restart: A Status Page for Encryption and Downtime

AI 導讀

Tomo 在自己的 Supabase 專案測試強制 SSL 和三種重啟,並製作顯示連線加密、資料庫重啟時間及停機窗口的狀態頁。啟用後,未加密連線會在密碼驗證前被拒;SSL 設定及 Fast database reboot 的心跳中斷為 17 至 21 秒,整個專案重啟則為 62 至 64 秒,API 呼叫失敗或逾時約 46 至 51 秒。

正文

"Enforce SSL on incoming connections" is one switch in the Supabase dashboard, and it comes with a warning: the database restarts. I wanted to know three things before I trusted it. Does it actually refuse unencrypted connections? How long is the interruption? And can I see both on one page afterwards? This post does the tests, shows the numbers, and builds a small status page on top.

AI disclosure: I used Claude to help draft this post. I ran every test in my own project, checked the results, and edited the text. The mistakes are mine.

Repo: https://github.com/tomozilla/ssl-enforcement-check-en
Plain-language companion (no code, no product names): https://medium.com/p/203aa18f400d

The finished status page: encrypted connection, database uptime, cron runs per minute and downtime windows.

What you will do

  1. Test the connection before the switch, using a deliberately wrong password so no real password is ever involved.
  2. Flip the switch and test again.
  3. Measure each kind of restart: the SSL setting, "Fast database reboot", and "Restart project".
  4. Build a status page that shows encryption, the last restart and downtime windows, and see what breaks while the database is down.

Before you start

  • Cost. Free plan, nothing billable in my run. The status page calls an Edge Function every 30 seconds: about 2,880 calls a day per open tab, about 86,000 a month (arithmetic). Free includes 500,000 invocations a month (Supabase docs, checked 2026-10-22), so one tab is about 17%. I did not look at the usage page afterwards, so I have no measured count.
  • Risk. Everything below restarts a demo database. Do not copy it onto production without picking a quiet time.
  • Security. The status function runs with Verify JWT off, protected only by a public key. Read "Security" at the end.
  • Not verified. verify-full with the downloaded certificate, big databases, paid plans.

1. The "before" test

Connect through the Session pooler (Dashboard, Connect). I use psql with sslmode=disable and sslmode=require, and a password that is wrong on purpose:

for mode in disable require; do
  printf "sslmode=%-8s -> " "$mode"
  PGPASSWORD=wrong-on-purpose PGCONNECT_TIMEOUT=10 psql \
    "host=$PGHOST port=5432 dbname=postgres user=$PGUSER sslmode=$mode" \
    -c "select 1" 2>&1 | head -1
done

With enforcement off:

sslmode=disable  -> psql: error: connection to server at "aws-0-<region>.pooler.supabase.com" (...), port 5432 failed: FATAL:  password authentication failed for user "postgres"
sslmode=require  -> psql: error: connection to server at "aws-0-<region>.pooler.supabase.com" (...), port 5432 failed: FATAL:  password authentication failed for user "postgres"

Both got as far as the password check. The unencrypted attempt reached the front door; with the right password it would have been let in, unencrypted.

Database Settings:

2. Flip the switch

Database Settings, switch on Enforce SSL on incoming connections. A dialog warns about the restart:

Dialog:

After the restart, the same test:

sslmode=disable  -> ... FATAL:  (ESSLREQUIRED) SSL connection is required for user: postgres
sslmode=require  -> ... FATAL:  password authentication failed for user "postgres"

Now the unencrypted attempt is refused before the password is even checked. That is the setting doing its job. (The exact wording can differ with your psql version.)

3. Measure the restart three ways

A wrong-password test is not a good outage detector: a refusal like that may be answered by the pooler without reaching the database (my guess; I did not test it here). So I used three other signals.

An external poller. A small script calls the status function every few seconds and prints the HTTP status and the database start time:

export STATUS_URL="https://<PROJECT_REF>.supabase.co/functions/v1/security-status"
export PUBLISHABLE_KEY="<your publishable key>"
./scripts/poll.sh 3

The database's own start time. select pg_postmaster_start_time(); changes when Postgres restarts.

A heartbeat. pg_cron runs inside the database, so it does not run while the database is down. I schedule a job every 10 seconds and look for gaps in its history:

select cron.schedule('ssl-demo-heartbeat', '10 seconds', 'select 1');
with runs as (
  select start_time, lag(start_time) over (order by start_time) as prev
  from cron.job_run_details
  where jobid = (select jobid from cron.job where jobname = 'ssl-demo-heartbeat')
    and start_time > now() - interval '30 minutes')
select to_char(prev, 'HH24:MI:SS') as last_run_before_utc,
       to_char(start_time, 'HH24:MI:SS') as next_run_utc,
       extract(epoch from (start_time - prev))::int as gap_seconds
from runs
where start_time - prev > interval '15 seconds'
order by start_time;

(15 seconds because a normal gap is 10 or 11: in my first 15 minutes of history, 90 gaps were 10 s and one was 11 s.)

Switching SSL off, then on, gave this (times in UTC, confirmed at 09:07:28 and 09:11:19):

Change Postgres started Failed API calls Heartbeat gap
SSL enforcement off 09:07:35 (about 7 s after I confirmed) 1 (HTTP 500) 18 s
SSL enforcement on 09:11:26 (about 8 s after) 1 21 s

The poller called roughly every 3 to 4 seconds, so each interruption was shorter than that spacing. The dialog says "a few minutes"; on this almost empty database it was not. A real database will be slower, and I have not measured one.

4. Not all restarts are the same

The dashboard has two other ways to restart. Under Project Settings, General, the Restart project button has a small arrow next to it. Its menu offers:

Fast database reboot: Restarts only the database service, with less downtime than a full project restart. Other project services remain running.

Project Settings: Restart project button with a dropdown arrow, and Pause project.

The Supabase SSL docs say that changing SSL enforcement triggers a fast database reboot, which matches what I saw. I could not find a public description of what "Restart project" restarts inside, so I measured it instead. Both dialogs warn of "a few minutes of downtime." I ran the fast reboot once and Restart project three times:

Action Heartbeat gap API calls
Fast database reboot 17 s 1 failed (HTTP 500). One unexplained 20 s timeout about a minute later, then fine
Restart project, run 1 62 s failed or timed out for about 51 s
Restart project, run 2 64 s about 48 s
Restart project, run 3 63 s about 46 s

Bar chart of heartbeat gaps: SSL off 18 s, SSL on 21 s, Fast database reboot 17 s, Restart project 62, 64 and 63 s.

SQL Editor result listing the gaps: 18, 21, 17 and 62 seconds.

The three database-only reboots (the two SSL changes and the fast reboot) show gaps of 17 to 21 seconds. Restarting the whole project shows about 62 to 64. If all you need is a database setting to apply, the lighter option looks clearly shorter in my runs. Tiny database, one region, a handful of runs: take it as a comparison, not a promise.

5. The status page

One HTML file and one Edge Function. The function is read-only: it asks the database three questions and returns them.

The browser asks the security-status function every 30 seconds; the function reads encryption state, start time and cron history from Postgres; a heartbeat job feeds the history.

// Is this function -> database connection encrypted? (pg_stat_ssl shows a connection its own state)
const [conn] = await sql`
  select ssl, version as tls_version, cipher, bits
  from pg_stat_ssl where pid = pg_backend_pid()`;

// When did Postgres start? (= the last restart)
const [db] = await sql`
  select pg_postmaster_start_time() as started_at, now() as now,
         current_setting('server_version') as server_version`;

// Gaps longer than GAP_SECONDS in the heartbeat job's history over 72 hours
const gaps = await sql`
  with runs as (
    select start_time, lag(start_time) over (order by start_time) as prev
    from cron.job_run_details
    where start_time > now() - interval '72 hours'
      and jobid = (select jobid from cron.job where jobname = ${HEARTBEAT_JOB}))
  select prev as from_ts, start_time as to_ts, extract(epoch from (start_time - prev))::int as seconds
  from runs where start_time - prev > make_interval(secs => ${GAP_SECONDS})
  order by start_time desc limit 20`;

It checks that the request carries your publishable key, which is an entry ticket and not authentication (see Security). To run it: copy app/config.example.js to app/config.js, put in your URL and publishable key, then cd app && python3 -m http.server 8765 --bind 127.0.0.1.

6. What went wrong when I used it

I reused a page I had built earlier for readers in Japan, then used it the way you would. These are the problems I hit, in order.

Every time said "Japan time". My first run showed "Last restart: 22/10/2026, 18:18:27 (Japan time)", with the zone hard-coded. That means nothing to most readers. The page now formats times in the viewer's own time zone and says so.

Short restarts were invisible. The page lists "downtime windows", but it only counted gaps longer than 25 seconds, a threshold I had picked for slower restarts. Three of my four early restarts (17, 18 and 21 seconds) did not appear. In the screenshot below, the 62-second gap is there and the three short ones are not:

Before: the downtime windows list shows only the 62-second restart and an unrelated 49-second gap; times say Japan time.

I looked at the heartbeat history to pick a better threshold: normal gaps were 10 s (90 times) and 11 s (once), restarts were 17 s and up. I set it to 15 seconds. Fifteen is tuned to my 10-second job and a small sample, so it will not suit other schedules.

Other cron jobs leaked in. The page was reading the history of every cron job in the project, and mine had another 10-second job from an earlier experiment until minutes before this run. Mixed histories confuse gap detection, and the 49-second "cause unknown" window in that screenshot is probably such an artifact; I did not trace that specific gap. The page now reads only the job named ssl-demo-heartbeat.

Fixing that created a new lie. With only the heartbeat's history, the chart drew 46 red minutes before the job existed, which reads as "down". Minutes before the job's first run are "no data", not "down", so the page now starts the chart at the first run.

The page stayed green while the database was down. This was the worst one. I opened the page, restarted the project from another tab, and clicked reload 30 seconds in. The call hung, and the page kept showing the last good state: green "Encrypted", "Up 7min 22s", no error at all.

Before: during a restart the page still shows a green Encrypted dot and Up 7min 22s, with no warning.

A status page that stays green while the database is down is worse than no page. The fix is small: a 10-second timeout on the request, and keeping the stale marking while a new request is in flight. Now it says what is wrong and dims the old data:

After: a red banner says

Once the database is back, the page recovers by itself on the next refresh. This is right after the third restart, with the orange dot saying it restarted only a moment ago:

The page after recovery: Up 46s with an orange dot, and the latest gap labelled Database restart.

Limits I did not fix.

  • Postgres only reports the last start time, so older gaps are labelled "cause unknown". Recording the start time into a table on a schedule would fix that. I did not try it.
  • The 15-second threshold, as above.
  • One 20-second timeout appeared a minute after the fast reboot, and I do not know why.

Security

  • security-status runs with Verify JWT off and checks for your publishable key. That key ships in the page, so anyone with the page has it. It is read-only, but for a public page add a real login.
  • No real database password appears anywhere: the test uses a wrong one on purpose. Do not put your real password into scripts or environment variables.
  • config.js is git-ignored. Only the publishable key goes in it, never a service_role or secret key.

Cleanup

select cron.unschedule('ssl-demo-heartbeat');

Delete the security-status function, and switch "Enforce SSL" back if you do not want it.

Recap

  • With enforcement off, an unencrypted attempt reaches the password check. With it on, the attempt is refused first.
  • On my small demo, the SSL setting and a fast database reboot each showed a heartbeat gap of 17 to 21 seconds and one failed API call. Restarting the whole project showed 62 to 64 seconds and 46 to 51 seconds of failing calls.
  • A status page has to survive the thing it reports on: timeouts, stale marking and "no data" are not optional.
  • Everything here was measured on an almost empty database. I did not test verify-full.

The code is in the repo: https://github.com/tomozilla/ssl-enforcement-check-en. The plain-language version for non-technical readers: https://medium.com/p/203aa18f400d.

來源:DEV Community · dev.to