# We hired a security engineer and got back 123 findings

> Source: <https://dev.profullstack.com/~anthony/blog/004-post.html>
> Published: 2026-08-16 04:46:12+00:00

# 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
records, the pull requests, and my own notes taken while doing the remediation. Every count in this
post came out of the GitHub advisory API and the Postgres catalogs, not out of memory. The
advisories themselves are private to the repos, so you'll have to take the numbers on
faith.

## The setup

Eduardo Camarillo joined Profullstack this month as our first dedicated security engineer. I gave
them two repositories and no scope restrictions: **CoinPay Portal**, our self-hosted
crypto payment processor, and **QryptChat**, our post-quantum encrypted messenger. Both are
live. Both handle exactly the kind of thing you do not want handled badly — one moves money,
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
GitHub security advisories:

**CoinPay Portal**— 86 confirmed findings: 3 critical, 39 high, 29 medium, 15 low. The critical and high ones were filed individually; the mediums and lows arrived as a single umbrella “remediation list” document. Three more critical/high reports landed afterward, separately.**QryptChat**— 37 vulnerabilities plus 37 architectural deficiencies, 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
for a fixed advisory — there is no separate “closed.” The fixes went out across
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
the kind of headline that is usually doing marketing work. A meaningful fraction of these were
*latent* — real defects in the code or the schema, genuinely exploitable under a
plausible near-future configuration, but not reachable in production as it was deployed that
morning. More on why that distinction nearly tripped me up in a minute. A handful of others were
duplicates of one root cause across a family of call sites. What is not negotiable is the other
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
`SECURITY DEFINER`

Postgres function named `delete_encrypted_data_only`

,
callable by the `anon`

role. Any person on the internet with the public API key —
which ships in the browser bundle, because that is what a public anon key *is* — could
invoke it. That is not a vulnerability so much as an unlabeled button wired to the building's
demolition charges. It was one of 48 `SECURITY DEFINER`

functions in the public schema
reachable by `anon`

. All 48 are now unreachable, verified by querying
`has_function_privilege`

over `pg_proc`

rather than by trusting the
migration.

**Every user's phone number, readable by every user.** QryptChat's row-level
security policy on the users table was `USING (true)`

. Any authenticated account could
read every row: phone numbers, identifiers, the lot. It is now own-row OR
`shares_conversation_with(id)`

. The busiest account on the platform went from being able
to see all 86 users to seeing 14.

**Unauthenticated identity spoofing.** A `sync_user_with_auth`

RPC
accepted a phone number and overwrote the record. No authentication.

**Hardcoded service-role key fallbacks.** On CoinPay Portal, the specific literal
Eduardo cited had already been removed — but the pattern hadn't. Sixteen *other* files fell
back to the anon key or an empty string when the service-role key was missing. Falling back to anon
does not throw. It silently downgrades, RLS begins applying to code written on the assumption that
it does not, and the symptom surfaces three layers away from the cause a week later. Every one of
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
reputation-receipts table. This one is interesting specifically because almost all of its
neighbours in the report were

*not*live, and I only know which was which because I stopped 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
`authenticated`

. If you then write ```
REVOKE SELECT (secret_column) ON users FROM
authenticated
```

, you have subtracted nothing, because the table-level grant already permits
every column including that one. It applies cleanly. It reports success. It does nothing.

I shipped that mistake and caught it only by checking
`has_column_privilege()`

afterwards. To actually restrict a column you must revoke the
table grant and then grant back the specific columns you want. Never assume a REVOKE landed
— ask the database whether the privilege is still there.

### 2. Verify RLS findings by assuming the role, not by reading the policy

The technique that made this audit tractable: wrap the check in a `DO`

block,
`set local role anon;`

, attempt the read, then `raise exception`

to roll the
whole thing back and carry the result out in the error message. It is safe to run against
production.

Doing this on CoinPay Portal showed that most of the RLS findings were latent. The app
authenticates with its own JWT and talks to the database as `service_role`

, so
`auth.uid()`

is never populated, and every policy keyed on it denies *everyone*.
The policies were wrong, they needed fixing, and a future refactor would have detonated them —
but they were not currently leaking. That same check isolated the one finding that genuinely was.
Had I skipped it I would have announced “11 vulnerabilities fixed” and been wrong in
both directions at once: overstating the exposure of ten, and burying the one that mattered.

### 3. Revoking EXECUTE can cause a silent write outage — check pg_trigger first

This is the expensive one, and the reason I am writing this post rather than just closing the advisories.

In the sweep that locked down those 48 functions, we revoked `EXECUTE`

on
`calculate_message_expiration`

. Nothing called it directly. What called it was a
`BEFORE INSERT`

trigger on the messages table — and that trigger was the only one
on the table not marked `SECURITY DEFINER`

, so it executed with the privileges of
whoever was inserting. Permission denied. The trigger raised. The raise took the entire INSERT down
with it.

**Message sending on QryptChat was dead for two days** and nothing alerted. It
surfaced as an application 500, and I found it by noticing the newest row in the table was 08-13
when it was the 16th. The fix was to make the trigger function `SECURITY DEFINER`

,
matching its sibling, rather than granting the function back.

The general rule: before any EXECUTE revoke, join `pg_trigger`

against the functions
you are about to revoke and check `prosecdef`

on each trigger's function. A hardening
sweep that takes production down teaches your team that hardening sweeps take production down, and
that lesson is much harder to unlearn than the CVE was to fix.

### 4. A missing RPC leaves no trace in your database logs

We merged the CoinPay Portal remediation PR before applying its migration — about a twenty minute gap. In that window, production was calling a database function that did not exist yet, on every single payment creation. It fails closed with a 500.

Here is the part worth knowing: **PostgREST rejects a call to an unknown function before it
ever reaches Postgres**, so it produces *no* entry in `postgres_logs`

. Quiet
database logs are not evidence that a schema/code mismatch is harmless. Nothing hit that path in
those twenty minutes, and that was luck, not design. On a repo like this the migration is not
optional and not deferrable: merge and apply together, then
`notify pgrst, 'reload schema';`

so the API's cache picks the function up.

## What we deliberately did not fix

An audit report is a set of claims, not a set of orders. A few items on the CoinPay Portal list were closed without a code change, and I think saying so publicly is more useful than a clean scoreboard:

**Arbitrary payee wallet addresses.** Flagged as a vulnerability; it is the product. Paying an invoice to a third party is a real flow. We hardened it instead — the platform's own wallet can't be targeted, and the authorizing actor is recorded on every invoice — rather than requiring the payer to own the destination.**A public-lookup function reported as an IDOR.** It returns public profile columns and is a lookup by design. Noted in the code so nobody “fixes” it again in six months.**A dependency CVE with no upstream fix.** The vulnerable package was newest published; there was nowhere to upgrade to. It turned out to be reachable only through four declared wallet-adapter dependencies that*nothing in the codebase imported*— they were referenced by one line of build config. Deleting them removed the CVE, and about a dozen transitive packages with it.

That last one is my favourite finding of the whole audit, and it isn't even a vulnerability. The fastest way to fix a dependency you can't patch is frequently to discover you were never using it.

## Was it worth it?

We took a two-day messaging outage, I spent a week doing nothing but remediation, and the test suite needed 21 tests rewritten because they had been quietly relying on a service client that never got constructed.

Against that: an unauthenticated data-destruction endpoint, a table of payment amounts open to the world, and every user's phone number one query away from every other user — all found by someone reading our code on purpose, before they were found by someone reading our code on purpose.

The thing I did not anticipate is how much of the value was in the second-order lessons. Nobody files an advisory titled “your REVOKE statements are decorative.” You only learn that by doing the remediation with a paranoid person looking over your shoulder, and then checking the catalogs instead of believing the migration output.

Welcome aboard, Eduardo. Please give me a week before you start on the next repo.

Counts in this post were pulled from the GitHub security advisory API for
`profullstack/coinpayportal`

and `profullstack/qryptchat-web`

on 2026-08-16
(46 and 17 advisories respectively), and from the remediation list enumerated in each repo's
umbrella advisory. The advisories are private to the repositories. CoinPay Portal is at
[coinpayportal.com](https://coinpayportal.com/) and QryptChat at
[qrypt.chat](https://qrypt.chat/).
