← Journal

2026-04-14

The controls you don't see

Two days after shipping the invite-only gate for the beta tools, I ran a full security audit on it.

I found five vulnerabilities. Two of them were critical.

That's not a statement about the quality of the initial build. It's a statement about what happens when you build something end-to-end in a few days and then stop to actually look at it. You find things. That's the point.


The most serious problem was a logic inversion in the access check.

The gate was supposed to work like this: you have an active session, your per-tool access grant exists and hasn't expired, you get in. Everything else gets the locked screen.

The code did the opposite. It checked whether your grant was expired. If you had no grant at all — which is the case for anyone who had authenticated through any other mechanism — the check returned nothing, fell through, and authorized you. An absent grant should be the hardest lock. It was an open door.

The fix was one condition. if (expiresAt && expired) became if (!expiresAt || expired). The logic now requires a valid, non-expired grant to exist. Anything else blocks.


The second critical issue was where the access grant was stored.

Supabase user accounts have two metadata objects: user_metadata, which the user can write to directly via the client SDK, and app_metadata, which only the server-side admin client can modify.

The gate was reading from user_metadata.

That means any authenticated user could call supabase.auth.updateUser() from their browser, write their own tool_access entry with an expiry date ten years from now, and grant themselves indefinite access to any tool. No invite code required. No email. Just an API call.

Moving the grant to app_metadata closes that completely. The browser cannot write to it. Only the Edge Function running with the service role key can set it — and the Edge Function only sets it after validating a legitimate invite code.


The other three issues were real but less severe.

Both Edge Functions were responding with Access-Control-Allow-Origin: *. For public static pages, that's fine — the HTML is meant to be read by anyone. For an API endpoint that grants access to private tools, it means any website anywhere can call it. Replaced the wildcard with an explicit allowlist: alexsoe.io and localhost for development.

There were no HTTP security headers on the site. No Content-Security-Policy. No X-Frame-Options. No Strict-Transport-Security. These don't stop the invite gate vulnerabilities, but they're the foundation of browser-level security — they tell the browser what's allowed to run, what's allowed to embed the page, and that HTTPS should always be used. Added all five headers via a Cloudflare Pages _headers file.

And there was no rate limiting on the invite code redemption endpoint. An automated script could try thousands of codes in minutes. Added a per-IP limit of ten attempts per fifteen-minute window using a database table the Edge Function checks on every request. The eleventh attempt in a window gets a 429 — try again later.


None of this changes what the tools look like. The interface is identical. The numbers are the same. The user experience is unchanged.

That's fine. That's the point.

Security controls that work are invisible. You notice them when they fail, and you notice their absence when someone tells you what they found. I'd rather be the one who found it.


There's a version of building in public where you only post the progress. The launch days. The features. The things that look good.

That's not this.

If I'm going to ask people to trust me with their financial data — which is what every tool in the lab implicitly asks — then the controls underneath have to be real. And real controls get audited. Not once, at the start, and never again. Regularly. Because the threat model changes, the code changes, and the gaps you missed the first time don't go away just because you didn't look.

I looked. I found five things. I fixed them. I shipped it.

That's the job.