We hired a security engineer and got back 123 findings — Chovy's Blog
We hired a security engineer and got back 123 findings
2026-08-16, by Anthony “chovy” Ettinger.
How this was written: drafted with an AI assistant from the advisory<br>records, the pull requests, and my own notes taken while doing the remediation. Every count in this<br>post came out of the GitHub advisory API and the Postgres catalogs, not out of memory. The<br>advisories themselves are private to the repos, so you'll have to take the numbers on<br>faith.
The setup
Eduardo Camarillo joined Profullstack this month as our first dedicated security engineer. I gave<br>them two repositories and no scope restrictions: CoinPay Portal , our self-hosted<br>crypto payment processor, and QryptChat , our post-quantum encrypted messenger. Both are<br>live. Both handle exactly the kind of thing you do not want handled badly — one moves money,<br>the other is supposed to be the app where the contents are nobody's business but yours.
I expected a report. What arrived over about seventy-two hours was a filing cabinet.
The count
Across the two repositories, 123 confirmed findings , tracked as 63 individual<br>GitHub security advisories:
CoinPay Portal — 86 confirmed findings: 3 critical, 39 high, 29 medium,<br>15 low. The critical and high ones were filed individually; the mediums and lows arrived as a single<br>umbrella “remediation list” document. Three more critical/high reports landed<br>afterward, separately.
QryptChat — 37 vulnerabilities plus 37 architectural deficiencies,<br>filed as 17 advisories: 2 critical, 11 high, 4 medium.
As of today all 63 advisories are published, which on GitHub is the terminal state<br>for a fixed advisory — there is no separate “closed.” The fixes went out across<br>roughly a dozen pull requests and about ten database migrations applied to production.
I want to be honest about the shape of that number, because “123 vulnerabilities” is<br>the kind of headline that is usually doing marketing work. A meaningful fraction of these were<br>latent — real defects in the code or the schema, genuinely exploitable under a<br>plausible near-future configuration, but not reachable in production as it was deployed that<br>morning. More on why that distinction nearly tripped me up in a minute. A handful of others were<br>duplicates of one root cause across a family of call sites. What is not negotiable is the other<br>end: some of these were live, unauthenticated, and bad.
The five that made my stomach drop
Ranked by how long I stared at the screen after reading the title.
Unauthenticated remote data destruction. QryptChat had a<br>SECURITY DEFINER Postgres function named delete_encrypted_data_only,<br>callable by the anon role. Any person on the internet with the public API key —<br>which ships in the browser bundle, because that is what a public anon key is — could<br>invoke it. That is not a vulnerability so much as an unlabeled button wired to the building's<br>demolition charges. It was one of 48 SECURITY DEFINER functions in the public schema<br>reachable by anon. All 48 are now unreachable, verified by querying<br>has_function_privilege over pg_proc rather than by trusting the<br>migration.
Every user's phone number, readable by every user. QryptChat's row-level<br>security policy on the users table was USING (true). Any authenticated account could<br>read every row: phone numbers, identifiers, the lot. It is now own-row OR<br>shares_conversation_with(id). The busiest account on the platform went from being able<br>to see all 86 users to seeing 14.
Unauthenticated identity spoofing. A sync_user_with_auth RPC<br>accepted a phone number and overwrote the record. No authentication.
Hardcoded service-role key fallbacks. On CoinPay Portal, the specific literal<br>Eduardo cited had already been removed — but the pattern hadn't. Sixteen other files fell<br>back to the anon key or an empty string when the service-role key was missing. Falling back to anon<br>does not throw. It silently downgrades, RLS begins applying to code written on the assumption that<br>it does not, and the symptom surfaces three layers away from the cause a week later. Every one of<br>them now routes through a client that refuses to construct without a real key.
13,711 rows of payment amounts readable by anon. A CoinPay Portal<br>reputation-receipts table. This one is interesting specifically because almost all of its<br>neighbours in the report were not live, and I only know which was which because I stopped<br>reading policies and started assuming roles. Which brings me to the actual point of this post.
Four things I learned that generalize
The findings themselves are ours. These four are yours too, if you run Postgres behind an app.
1. Column-level REVOKE is a no-op against a table-level grant
Supabase grants table-level SELECT to anon and<br>authenticated. If you then write REVOKE SELECT (secret_column) ON users FROM<br>authenticated, you have subtracted nothing, because the table-level grant already permits<br>every...