A guide

How do I check if my vibe-coded app is secure?

Run seven checks on your live app and its project: secret keys in the code the browser loads, Supabase tables without row-level security, endpoints that answer without a sign-in, public storage buckets, missing rate limits, secrets in the repository, and rules that only the screen enforces. Each check takes minutes. Run them only on your own app. Each check can show a problem. A check that finds nothing means only that it found nothing where you looked.

A hand filing a card into a desk sorter where the other cards are already in order.

1. Secret keys in the code the browser loads

Anything your app sends to the browser, anyone can read. Some settings are copied into the browser code on purpose: in a Vite app, every variable that starts with VITE_; in a Next.js app, every variable that starts with NEXT_PUBLIC_. A secret key placed in one of those is public.

  • How to check: open your live app, open the browser's developer tools and search the loaded scripts for sk_live_ and rk_live_ (Stripe's secret and restricted keys, both secret), sb_secret_ (a Supabase secret key), and the name of every AI, email or payment service the app uses. The browser only has the scripts of the pages you opened, so sign in and open every page first; a search that finds nothing covers only what has loaded.
  • Also check the source, which covers the pages you did not open: search the project for every variable that starts with VITE_ or NEXT_PUBLIC_ and confirm none of them holds a secret, then search the code that runs in the browser for the same key prefixes, in case a key was pasted in directly.
  • Search for eyJ too, whichever keys your project shows. Supabase's legacy keys are two long keys that both start with eyJ, and the word service_role does not appear in plain text inside the secret one, so searching for it finds nothing. Compare each key you find with the anon and service_role keys on the API keys page of your Supabase project settings. A key that matches the service_role key is the secret one, and it is public.
  • Not a leak: a Stripe key that starts with pk_ and a Supabase key that starts with sb_publishable_. Both are made to be public. A key that starts with eyJ is public only when it matches the anon key, as above.
  • The fix: move the call that needs the secret into a server function, keep the key in the server's settings, then rotate the key. The old one has already been seen.

2. Supabase tables without row-level security

If your app stores its data in Supabase, the browser reads the database with the publishable key. That key is public, so row-level security (RLS) is what keeps strangers out of your rows. Supabase's own Security Advisor treats a public table without RLS as an error: anyone with your project URL can read, edit and delete all of its data.

  • How to check: in the Supabase dashboard, open the Security Advisor and look for "RLS Disabled in Public". Then read each policy: a read policy for the anon or public role (or with no role given) whose rule is just true lets every visitor read every row.
  • Then test it with two requests to the API, one with only the publishable key and one as a signed-in test user. A policy for the authenticated role with no row check lets every signed-in user read everyone's rows, and your app's screens will not show it. The Supabase guide under related answers below has both requests.
  • The fix: turn on RLS for every table in the public schema, and write policies that tie each row to its owner, such as auth.uid() = user_id. Add the policies in the same change and then try the app: with RLS on and no policy, every screen that reads the table goes empty. The Supabase guide under related answers below walks through it.

3. Endpoints that answer without a sign-in

Server functions and API routes often run with more access than a visitor has. One that never checks who is calling will answer anyone who finds its address. MITRE lists this as CWE-306, missing authentication for critical functionality.

  • How to check: open your app with the developer tools' Network tab, use a feature, and copy one of your own requests. Pick one that only reads, or that changes test data: if the function is open, the copy really runs. Send it again from a terminal the way a stranger could: without the sign-in cookie, and with your app's public key in place of your user token where the request carries one. If it still returns data or changes something, it is open. A refusal shows only that this one request was refused, so repeat for each function and API route.
  • The fix: every function checks the signed-in user on the server before it reads or writes, and refuses the request when there is none.

4. Storage buckets set to public

A public bucket in Supabase Storage skips access control for reading: anyone who has a file's URL can open it. Uploads, deletes and moves still follow your policies. Public suits profile pictures and blog images. It does not suit invoices, IDs or contracts.

  • How to check: in the dashboard, open Storage and note which buckets are public. Open the URL of a file that should be private in a private window. A private bucket is only as closed as its policies. List them in the SQL editor: select policyname, roles, cmd, qual, with_check from pg_policies where schemaname = 'storage' and tablename = 'objects'; Read each rule as in check 2: one that names no bucket_id applies to every bucket, and one that names a bucket but checks nothing about the user opens that bucket to everyone in its role.
  • The fix: make buckets that hold private files private, and serve each file through a signed URL your server creates, which works for a limited time.

5. No rate limits

Without a limit, one person or one script can call your app as often as it likes. When a call costs you money, such as an AI model, an email or a text message, that becomes a bill. OWASP lists it among its ten API security risks as unrestricted resource consumption.

  • How to check: list every function that calls a paid service or sends a message, and look for a limit per user or per address. If you cannot find one in your code or in your host's settings, assume there is none. If you find one, confirm it runs on the server and test it: from a terminal, call the function a few times more than the limit allows, ten calls at most, with a test recipient so no real message goes out. If the calls past the limit keep working, the limit is not enforced.
  • The fix: a limit on how often each user or address can call each function, plus a spending limit in each paid service's own dashboard.

6. Secrets in the repository

A key committed once stays in the Git history after the file is deleted. GitHub's secret scanning looks through the whole history of every branch for hardcoded credentials, free on public repositories.

  • How to check: search the repository and its history for .env files and for the key prefixes in check 1, eyJ included. On GitHub, open the repository's secret scanning alerts if it has them.
  • The fix: rotate every key you find, right away. GitHub's own advice is that cleaning the history is slow and often unnecessary once the key is revoked.

7. Rules that only the screen enforces

Hiding a button is not a permission. If only the page decides who may open the admin area or press Delete, anyone can skip the page and call the database or the function directly. MITRE names this CWE-602, client-side enforcement of server-side security.

  • How to check: for each thing only some users may do, such as admin pages, other people's records or refunds, copy the request while signed in as someone allowed, as in check 3. Send it again with an ordinary account's sign-in in place of the first. Also try another user's record id, and a change to a field you should not control, such as your own role. If any of it works, the rule lives only on the screen. Use test accounts and test records only: when the rule is missing, the refund, the delete or the role change really happens.
  • The fix: put the rule where the data is, in an RLS policy or a check on the server. Keep the hidden button only to tidy the screen.

Where LOJIK fits

Everything above you can do on your own. If you want a second look, LOJIK's Rescue page is for apps built with tools like these that got stuck.

See the Rescue page

The page opens with a free self-check. No sign-in.

When LOJIK is not the right fit

LOJIK is probably not the right fit if:

  • A developer or a security team already runs these checks for you.
  • A customer or a regulator asks for a formal security audit or a penetration test. Hire a firm that does that work.
  • The app is not your own. Test only your own apps, or ones whose owner gave you written permission.

Questions

Is the Supabase anon key a secret?

No. The publishable key, called the anon key in older projects, is made to be public: it reaches only what row-level security allows. The secret key, called service_role in older projects, skips row-level security and must never be in the browser.

Which check should I do first?

The two a stranger can reach without an account: secret keys in the browser code, and tables without row-level security. If a secret key was exposed, rotate it at once and move the call that uses it to the server.

Can I run these checks on someone else's app?

No. Run them only on your own app, or with the owner's written permission.

Product names belong to their owners. LOJIK is not affiliated with them, and they are named only to say what they are.

Sources