{"slug": "enforce-ssl-on-supabase-then-measure-the-restart-a-status-page-for-encryption", "title": "Enforce SSL on Supabase, Then Measure the Restart: A Status Page for Encryption and Downtime", "summary": "A developer built a small test harness and status page to verify that Supabase's \"Enforce SSL on incoming connections\" setting actually rejects unencrypted connections and to measure the resulting database restart. Using psql with sslmode=disable and sslmode=require, the tests showed unencrypted attempts reached the password check before enforcement and were refused with an ESSLREQUIRED error afterward, while an external poller, pg_postmaster_start_time(), and a pg_cron heartbeat tracked downtime during the restart.", "body_md": "\"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.\n\n**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.\n\nRepo: [https://github.com/tomozilla/ssl-enforcement-check-en](https://github.com/tomozilla/ssl-enforcement-check-en)\n\nPlain-language companion (no code, no product names): [https://medium.com/p/203aa18f400d](https://medium.com/p/203aa18f400d)\n\n`verify-full` with the downloaded certificate, big databases, paid plans.\nConnect through the Session pooler (Dashboard, Connect). I use `psql` with `sslmode=disable` and `sslmode=require`, and a password that is wrong on purpose:\n\n``` php\nfor mode in disable require; do\n  printf \"sslmode=%-8s -> \" \"$mode\"\n  PGPASSWORD=wrong-on-purpose PGCONNECT_TIMEOUT=10 psql \\\n    \"host=$PGHOST port=5432 dbname=postgres user=$PGUSER sslmode=$mode\" \\\n    -c \"select 1\" 2>&1 | head -1\ndone\n```\n\nWith enforcement off:\n\n``` php\nsslmode=disable  -> psql: error: connection to server at \"aws-0-<region>.pooler.supabase.com\" (...), port 5432 failed: FATAL:  password authentication failed for user \"postgres\"\nsslmode=require  -> psql: error: connection to server at \"aws-0-<region>.pooler.supabase.com\" (...), port 5432 failed: FATAL:  password authentication failed for user \"postgres\"\n```\n\nBoth 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.\n\nDatabase Settings, switch on **Enforce SSL on incoming connections**. A dialog warns about the restart:\n\nAfter the restart, the same test:\n\n``` php\nsslmode=disable  -> ... FATAL:  (ESSLREQUIRED) SSL connection is required for user: postgres\nsslmode=require  -> ... FATAL:  password authentication failed for user \"postgres\"\n```\n\nNow 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.)\n\nA 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.\n\n**An external poller.** A small script calls the status function every few seconds and prints the HTTP status and the database start time:\n\n```\nexport STATUS_URL=\"https://<PROJECT_REF>.supabase.co/functions/v1/security-status\"\nexport PUBLISHABLE_KEY=\"<your publishable key>\"\n./scripts/poll.sh 3\n```\n\n**The database's own start time.** `select pg_postmaster_start_time();` changes when Postgres restarts.\n\n**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:\n\n```\nselect cron.schedule('ssl-demo-heartbeat', '10 seconds', 'select 1');\nwith runs as (\n  select start_time, lag(start_time) over (order by start_time) as prev\n  from cron.job_run_details\n  where jobid = (select jobid from cron.job where jobname = 'ssl-demo-heartbeat')\n    and start_time > now() - interval '30 minutes')\nselect to_char(prev, 'HH24:MI:SS') as last_run_before_utc,\n       to_char(start_time, 'HH24:MI:SS') as next_run_utc,\n       extract(epoch from (start_time - prev))::int as gap_seconds\nfrom runs\nwhere start_time - prev > interval '15 seconds'\norder by start_time;\n```\n\n(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.)\n\nSwitching SSL off, then on, gave this (times in UTC, confirmed at 09:07:28 and 09:11:19):\n\n| Change | Postgres started | Failed API calls | Heartbeat gap | \n|---|---|---|---|\n| SSL enforcement off | 09:07:35 (about 7 s after I confirmed) | 1 (HTTP 500) | 18 s | \n| SSL enforcement on | 09:11:26 (about 8 s after) | 1 | 21 s | \n\nThe 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.\n\nThe 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:\n\n**Fast database reboot:** Restarts only the database service, with less downtime than a full project restart. Other project services remain running.\n\nThe 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:\n\n| Action | Heartbeat gap | API calls | \n|---|---|---|\n| Fast database reboot | 17 s | 1 failed (HTTP 500). One unexplained 20 s timeout about a minute later, then fine | \n| Restart project, run 1 | 62 s | failed or timed out for about 51 s | \n| Restart project, run 2 | 64 s | about 48 s | \n| Restart project, run 3 | 63 s | about 46 s | \n\nThe 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.\n\nOne HTML file and one Edge Function. The function is read-only: it asks the database three questions and returns them.\n\n``` php\n// Is this function -> database connection encrypted? (pg_stat_ssl shows a connection its own state)\nconst [conn] = await sql`\n  select ssl, version as tls_version, cipher, bits\n  from pg_stat_ssl where pid = pg_backend_pid()`;\n\n// When did Postgres start? (= the last restart)\nconst [db] = await sql`\n  select pg_postmaster_start_time() as started_at, now() as now,\n         current_setting('server_version') as server_version`;\n\n// Gaps longer than GAP_SECONDS in the heartbeat job's history over 72 hours\nconst gaps = await sql`\n  with runs as (\n    select start_time, lag(start_time) over (order by start_time) as prev\n    from cron.job_run_details\n    where start_time > now() - interval '72 hours'\n      and jobid = (select jobid from cron.job where jobname = ${HEARTBEAT_JOB}))\n  select prev as from_ts, start_time as to_ts, extract(epoch from (start_time - prev))::int as seconds\n  from runs where start_time - prev > make_interval(secs => ${GAP_SECONDS})\n  order by start_time desc limit 20`;\n```\n\nIt 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`.\n\nI 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.\n\n**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.\n\n**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:\n\nI 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.\n\n**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`.\n\n**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.\n\n**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.\n\nA 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:\n\nOnce 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:\n\n**Limits I did not fix.**\n\n`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.\n\n```\nselect cron.unschedule('ssl-demo-heartbeat');\n```\n\nDelete the `security-status` function, and switch \"Enforce SSL\" back if you do not want it.\n\nThe 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).", "url": "https://wpnews.pro/news/enforce-ssl-on-supabase-then-measure-the-restart-a-status-page-for-encryption", "canonical_source": "https://dev.to/tomozilla/enforce-ssl-on-supabase-then-measure-the-restart-a-status-page-for-encryption-and-downtime-1oon", "published_at": "2026-10-23 04:02:00+00:00", "updated_at": "2026-10-23 04:11:56.366856+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools"], "entities": ["Supabase", "PostgreSQL", "psql", "pg_cron", "Claude", "GitHub"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/enforce-ssl-on-supabase-then-measure-the-restart-a-status-page-for-encryption", "markdown": "https://wpnews.pro/news/enforce-ssl-on-supabase-then-measure-the-restart-a-status-page-for-encryption.md", "text": "https://wpnews.pro/news/enforce-ssl-on-supabase-then-measure-the-restart-a-status-page-for-encryption.txt", "jsonld": "https://wpnews.pro/news/enforce-ssl-on-supabase-then-measure-the-restart-a-status-page-for-encryption.jsonld"}}