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

> Source: <https://dev.to/tomozilla/enforce-ssl-on-supabase-then-measure-the-restart-a-status-page-for-encryption-and-downtime-1oon>
> Published: 2026-10-23 04:02:00+00:00

"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](https://github.com/tomozilla/ssl-enforcement-check-en)

Plain-language companion (no code, no product names): [https://medium.com/p/203aa18f400d](https://medium.com/p/203aa18f400d)

`verify-full` with the downloaded certificate, big databases, paid plans.
Connect through the Session pooler (Dashboard, Connect). I use `psql` with `sslmode=disable` and `sslmode=require`, and a password that is wrong on purpose:

``` php
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:

``` php
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, switch on **Enforce SSL on incoming connections**. A dialog warns about the restart:

After the restart, the same test:

``` php
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.)

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.

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.

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 | 

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.

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

``` php
// 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`.

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:

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.

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:

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:

**Limits I did not fix.**

`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.`config.js` is git-ignored. Only the publishable key goes in it, never a `service_role` or secret key.

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

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

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