We hired a security engineer and got back 123 findings Profullstack's first security engineer, Eduardo Camarillo, identified 123 confirmed vulnerabilities across the CoinPay Portal and QryptChat repositories, filed as 63 GitHub security advisories, including critical issues such as unauthenticated remote data destruction and exposure of all users' phone numbers. The fixes were deployed across roughly a dozen pull requests and ten database migrations, with all advisories now published. 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/ .