How a one-person SaaS hardens itself
Trim hit production with auth, payments, and pilot data — and a startled realization that nothing on the security side was load-bearing yet. The hardening sprint that followed wasn't sexy. It was three migrations and one audit, and it's the work that made the rest of the company possible to ship.
Trim shipped its first beta CFI in May. A week later I sat down to count the things that, if exploited, would end the company.
The list was long. Row-level security policies that weren't actually enforcing tenant isolation in the way I'd assumed. A submit-booking-lead endpoint with no rate limit. Invite links that exposed the recipient's email in the URL response payload. A SECURITY DEFINER function that was missing the explicit search_path setting. Three rows in the audit log that the schema said were immutable but the policies didn't actually prevent updates on.
Security work is the boring background that makes the visible product possible to ship.
The three migrations
The hardening sprint produced three Postgres migrations in two evenings. Numbered 0073, 0074, 0075.
0073 was the rate-limit infrastructure. A Postgres-backed bucket table with a SECURITY DEFINER function that does atomic ON CONFLICT DO UPDATE with fixed-window logic. A pg_cron task cleans expired buckets every fifteen minutes. The booking-lead RPC now caps at fifty per hour per CFI. If a scraper finds the endpoint, it gets cut off before it costs me real money.
0074 locked down student-transfer writes. The original policies allowed INSERT/UPDATE/DELETE from authenticated users with permissive policies that were tighter than they looked but not as tight as they should have been. The new migration adds RESTRICTIVE policies that block direct writes for anon and authenticated entirely. The legitimate transfer RPCs (owned by postgres, table not FORCE RLS) bypass and still work. Belt-and-suspenders against future permissive-policy regressions written by future-me at 11 PM.
0075 masked the email field on invite-token lookups. A CFI invites a student by email; the student opens the link; the link payload used to return the full email so the client could pre-fill it. That meant anyone who got the token could see a real email address. The new RPC returns email_masked (j**k@example.com) and a companion verify_invite_email() RPC lets the client check a typed email against the stored one without seeing it.
What this actually buys
Nothing visible. A real CFI using Trim sees no difference. A future bug-bounty report would have. A motivated attacker definitely would have. And every insurance carrier that ever asks Trim about its security posture — every future flight school chief who wants to know what happens if their student's data leaks — gets a serious answer instead of a hand-wavy one.
The reason to do this work now, before the customer base is large, is the same reason a CFI checks the squawks before each flight. The cost of finding the problem later is much higher than the cost of finding it now. The cost of NOT finding it before something bad happens is roughly "the whole company."
The pattern
Every Trim migration since 0073 has carried a WITH CHECK clause on every INSERT and UPDATE policy. Every SECURITY DEFINER function gets an explicit search_path. RPCs that return pre-auth data are explicit and audited. The default is "deny." The exception list is documented.
A one-person SaaS doesn't have a security team. The discipline has to be baked into the migration template instead. It costs about twenty minutes per migration to do right, and it would cost the company to do wrong. Worth the twenty minutes.