1
0 Comments

I vibe coded a SaaS. Here's every security layer I shipped before touching prod.

Let me be upfront about something.

I have no CS degree. I am not a security engineer. About two weeks ago I barely knew what RLS stood for.

I built Trakly (trakly.pro) using Cursor as my co-engineer for almost everything. React, TypeScript, Supabase, Stripe, Vercel. If that makes you nervous about the security of the app, I get it. Vibe coded apps have a reputation and a lot of it is deserved.

But I refused to ship something I wouldn't trust with my own financial data. So before a single real user signed up, I went deep on every security layer I could understand and implement. This post is everything I did, why I did it, and what I learned along the way.

First, the thing that scared me most: the database

Trakly stores financial transactions, budgets, savings goals, and user profiles. If someone could read another user's data, that's game over before you even launch.

Supabase uses Row Level Security (RLS) to solve this. Every table has a policy that says "you can only read and write your own rows." I enabled RLS on every single table: profiles, transactions, budget_categories, savings_goals, challenges, paycheck_plans, subscription_reviews, and signup_log.

I also ran a SQL query to verify it was actually enabled and every table returned true. That check cost me 30 seconds and gave me genuine peace of mind.

But RLS alone wasn't enough for the most sensitive fields. Trakly has a profiles table with columns like is_pro, subscription_status, and stripe_customer_id. If a clever client-side call could flip is_pro to true, that's a free Pro subscription forever.

The fix: a database trigger that locks those fields from client modification entirely. No matter what the frontend sends, the trigger rejects unauthorized changes at the database level. RLS protects rows. The trigger protects specific columns within rows. Two independent layers on the most sensitive data.

The billing stuff kept me up at night

Stripe is where real money lives. Getting this wrong doesn't just mean a bug report, it means financial loss.

Webhook signature verification: Every Stripe event hitting my webhook endpoint gets its signature verified against STRIPE_WEBHOOK_SECRET. If the signature doesn't match, the request is rejected immediately. This prevents anyone from faking a "payment succeeded" event to get free Pro access.

Webhook idempotency: Stripe retries webhooks when it doesn't get a 200 response fast enough. Without idempotency handling, the same payment event could process twice and cause duplicate state changes. I store each processed event ID in Upstash Redis with a 24 hour TTL. If the same event ID arrives twice, the second one gets a 200 and exits immediately without processing.

The free trial exploit I almost shipped: This one I only caught by paying myself $7 to subscribe to my own product before launch. My trial had expired. I went through the upgrade flow again. Stripe offered me ANOTHER 7 day free trial.

Anyone could cancel after 7 days, resubscribe, get another 7 days free, repeat forever. Infinite free trials with zero code needed.

The fix: before creating a Stripe Checkout session, I now check two things. First, does the user have any prior Stripe subscriptions in any status (active, canceled, past_due, trialing, ended)? Second, does their profile.subscription_status indicate they have ever had a subscription? If either check flags them, no trial period is added to the checkout session. Repeat users get charged immediately.

I also fixed the frontend: the upgrade modal was still showing "Start your 7 day free trial" to users who had already used their trial. The backend was correctly denying the trial but the UI was lying. Fixed by conditionally swapping the CTA copy based on subscription_status.

API security

Every serverless function in my api/ folder validates a Supabase bearer token before doing any work. No exceptions. I went through each route manually and confirmed this after reading about a similar app that had one unprotected endpoint that let anyone trigger expensive operations without auth.

Rate limiting sits on top of auth. I use Upstash Redis with the @upstash/ratelimit library. Every route has limits. Checkout and subscription endpoints have stricter limits. The unsubscribe endpoint (which is intentionally public) gets IP-based rate limiting instead of user-based since there is no auth token to key off.

The clientId validation on checkout deserves its own mention. When a user starts checkout, the client sends a clientId. The server does not just trust this value. It fetches the stripe_client_id directly from the profiles table for the authenticated user and compares. If they do not match, 403 and no checkout session is created. This prevents a theoretical attack where someone intercepts and replaces the clientId to create a checkout session under a different customer record.

The Vercel breach

Two weeks into building Trakly, Vercel confirmed a security breach. An employee had connected a third party AI tool to their Google Workspace with broad OAuth permissions. That tool got compromised. The attacker walked into Vercel's internal systems through the OAuth token and walked out with employee records, source code, and environment variables that were not marked as sensitive.

I had not marked any of my env vars as sensitive. Vercel's sensitive variable encryption required that my variable is either a production or a preview variable, so I changed and marked whatever I could as sensitive and changed the old values with new ones for every variable.

Within two hours I had rotated every secret key:

Stripe secret key and webhook secret
Resend API key
Upstash Redis credentials
CRON_SECRET and BACKUP_SECRET

The Supabase service role key was trickier since the rotate option was not obvious in the dashboard. I ended up regenerating the JWT signing keys which invalidates all existing sessions and issues new API keys. Three real users got logged out, but it was still worth it.

This whole experience reinforced something important: key rotation should be easy and practiced before you need it in an emergency. It took me almost 2 hours partly because I had never done it before. Next time it would take 20 minutes.

HTTP security headers

All of these live in vercel.json and apply to every response:

X-Content-Type-Options: nosniff prevents browsers from sniffing content types and potentially executing malicious content as a different MIME type than intended.

X-Frame-Options: DENY prevents Trakly from being embedded in an iframe, which is the attack surface for clickjacking.

Strict-Transport-Security with a two year max-age and preload tells browsers to only ever connect to trakly.pro over HTTPS, even if someone types the URL without https://.

Content-Security-Policy restricts which origins can load scripts, styles, images, and make connections. Yes, unsafe-inline is still in the script-src because React and Tailwind require it. This is a known tradeoff. The other directives (connect-src, img-src, frame-ancestors, base-uri, form-action) are properly locked down.

Referrer-Policy: strict-origin-when-cross-origin means the full URL is never sent in the Referer header to external domains, only the origin. This prevents leaking URL parameters to third parties.

Input sanitization

Every user-generated string that touches the database goes through a sanitization layer before it gets there. Transaction names, category names, notes, email addresses. The sanitizer strips control characters, normalizes whitespace, and enforces length limits defined in constants so no single field can hold an absurdly long string.

This is not XSS protection by itself since React handles that by default. It is protection against garbage data, unicode exploits, and users who try to store 50,000 character notes to abuse storage.

Database backups

A daily cron job runs at 3am UTC. It queries all seven user-owned tables, packages everything as JSON, and uploads to a private Supabase Storage bucket. The last 14 backups are kept and older ones are automatically deleted.

The bucket is private. No RLS policy allows client access. Only the service role can read or write. The cron endpoint requires a BACKUP_SECRET bearer token so it cannot be triggered by anyone who finds the URL.

I tested it by calling the endpoint manually with curl and verifying the file appeared in the bucket. Boring but important work.

What is still not perfect

I said I would be honest so here is what I know is still missing or imperfect:

No CAPTCHA on signup. Bots can mass-create accounts to farm the free tier or abuse the trial which is pretty unlikely but you never know what people can do sometimes. Supabase has hCaptcha integration and I will enable it if I see abuse post-launch.

CSP still has unsafe-inline. Removing it would break React's inline event handlers and Tailwind's style injection. Acceptable tradeoff for now.

No formal penetration testing. I am one person. I did my best but I did not pay a security firm to try to break in. At some point with real revenue this becomes worth doing.

Vercel allows env vars to be marked as sensitive on Production and Preview environments, which encrypts them at rest. Development variables cannot be marked sensitive which is a Vercel limitation worth knowing. My workaround: I keep a separate local .env file for development that never gets committed to GitHub, and only the Production and Preview vars in Vercel are marked sensitive. Not perfect but better than nothing.

The honest conclusion

I am not a security expert. I made mistakes along the way and I will probably find more after launch.

But I did the work. I read the docs. I asked questions. I ran the SQL queries. I rotated the keys early the next day after the breach happened. I dogfooded my own subscription and caught the trial exploit before any real user could hit it.

Vibe coded does not have to mean insecure. It means you used AI to help you build. The security decisions are still yours. The judgment calls are still yours. The responsibility is still yours.

If you are building something solo and wondering whether it is worth going this deep on security before you have a single paying user: it is... Not because you will definitely get attacked, but because shipping something you genuinely trust changes how you talk about it, how you sell it, and how you sleep at night.

Happy to answer questions about any of this. And if you spot something I missed, please tell me. Seriously.

One question for the community:

What security layer do you wish you had shipped earlier? What did you learn the hard way that I should know before my first 100 users show up?

on April 22, 2026
Trending on Indie Hackers
The most underrated distribution channel in SaaS is hiding in your browser toolbar User Avatar 185 comments I launched on Product Hunt today with 0 followers, 0 network, and 0 users. Here's what I learned in 12 hours. User Avatar 159 comments How are you handling memory and context across AI tools? User Avatar 100 comments I gave 7 AI agents $100 each to build a startup. Here's what happened on Day 1. User Avatar 98 comments Do you actually own what you build? User Avatar 59 comments Show IH: RetryFix - Automatically recover failed Stripe payments and earn 10% on everything we win back User Avatar 34 comments