Browse the blog
Sentrint Blog
AI-built apps · Checklist

Six security checks you can run on your own SaaS in 15 minutes

Six checks, none of which need a tool: row-level security off, a policy that allows everything, secrets in NEXT_PUBLIC_ or VITE_ variables, auth that only runs in the browser, .env in git history, and a webhook with no signature check. Numbers one and four cause most of the breaches you read about.

At the end of January 2026, security researchers at Wiz opened Moltbook, a social network for AI agents, and looked at the JavaScript the site sends to browsers. Inside one file they found a Supabase project URL and a publishable key.

That is not a finding on its own. Supabase publishable keys are designed to ship to browsers. So they ran one command to see whether the thing that makes that safe was switched on:

curl "https://<project>.supabase.co/rest/v1/agents?select=name,api_key&limit=3" \
  -H "apikey: sb_publishable_..."

It should have returned an empty array. It returned API keys.

The final count, from Wiz's writeup: 1.5 million agent authentication tokens, around 35,000 email addresses, 4,060 private conversations, roughly 4.75 million records. Some of those private messages contained plaintext OpenAI keys that users had shared with each other. Write access was open too, so any stranger could edit any post on the platform.

The founder had said publicly, before any of this, that he "didn't write a single line of code" for the platform. The whole thing traced back to one setting, and between the first report and the final fix about three hours passed.

Below are the six checks that catch that class of mistake. None of them need a tool. All six take about fifteen minutes.

1. Row-level security is off

Your Supabase publishable key is public by design. It ships in your frontend, and it is meant to. Row Level Security is the only thing standing between that key and every row in your database.

Check: Supabase dashboard, Table Editor. Look for the "RLS disabled" badge on each table.

A failure looks like: any table holding user data with that badge on it. That table is a public API endpoint, whether or not you meant to build one.

Why it survives: RLS is off by default on a new table, and nothing breaks when it is off. If anything your app works better that way, because nothing is being filtered.

Enable Row Level Security on every table in the public schema. For each one, add policies that restrict rows to the signed-in user who owns them. List every table you changed and every table you deliberately left public, with the reason.

2. RLS is on, but the policy allows everything

This is the second version of the first bug, and the more embarrassing one, because you already did the work. The warning went away, which felt like progress.

using (true) is a policy that permits every row to every caller. It is functionally identical to having no policy at all, and it satisfies the dashboard warning that made you write it.

Check: Supabase dashboard, Authentication, Policies. Read each one. If a policy never references auth.uid(), it is not restricting anything.

A failure looks like:

-- This is not a policy. This is a formality.
create policy "Enable read access for all users"
on public.orders for select
using (true);

The trap inside the trap: auth.uid() returns null when nobody is signed in, and in SQL null = user_id is never true. So a policy reading using (auth.uid() = user_id) does not error for an anonymous visitor. It silently matches nothing, which looks exactly like working correctly until it does not. Write it out:

using ( auth.uid() is not null and auth.uid() = user_id )

And one more thing most guides miss. Turning RLS on and adding a policy does not remove the privileges Supabase granted the table when it was created. Their own documentation says adding policies does not take those grants back. You need revoke all on table <name> from anon, authenticated; as a separate step. The four steps that actually close it walks through it properly.

3. Secrets in NEXT_PUBLIC_ or VITE_ variables

Those prefixes are not decoration. They are instructions to your build tool that say: compile this value into the bundle and ship it to every visitor.

Service role keys land there constantly, usually because the AI moved one to clear an error. The error went away, so nothing suggested the fix had cost anything.

Check:

grep -rn "NEXT_PUBLIC_\|VITE_" .env*

Then read each value and ask one question: would you be comfortable printing this on your homepage? If not, it is in the wrong variable.

A failure looks like: NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY. Or any sk-, any sk_live_, any database connection string.

What is fine: a Supabase publishable key, a Stripe publishable key (pk_), a Google Maps browser key with referrer restrictions. Those are public by design. The test is not where the key sits, it is what the key can do in a stranger's hands. The longer version is here.

Check the deployed app too, not just the file. Open your live site, open developer tools, and press Ctrl+Shift+F (Cmd+Shift+F on a Mac). That searches every loaded script at once, which is where things actually hide.

4. Auth that only runs in the browser

A redirect inside a React component is a suggestion. It is not authentication.

If your protection is if (!user) router.push('/login'), then the page is protected and the data behind it is not. The API call still answers. Anyone can make that call without ever loading your React app.

Check: curl your most sensitive API route with no session cookie.

curl -i https://yourapp.com/api/orders

A failure looks like: a 200 and a body full of data. You want a 401 or a 403.

Do it for every route that matters, not just the obvious one. Admin endpoints, export endpoints, anything under /api/admin, and the routes you added three weeks after launch and forgot about.

Move all authorisation to the server. Every API route must verify the session and the user's ownership of the resource before returning anything. Client-side redirects stay for user experience only, never as the security boundary. Show me every route that currently has no server-side check.

5. .env in git history

Deleting the file does not remove it. Git keeps every commit, and the commit that added the file still has the file in it.

If the repository was ever public, even for an afternoon, assume the keys are gone. Bots scan new public repositories continuously and they are faster than you are.

Check:

git log --all --full-history -- .env

Any output at all means those values were committed at some point. It does not matter how long ago, and it does not matter that the file is not there now.

A failure looks like: any commit listed. Not "an old commit". Any.

What to do: rotate the keys today, before you touch the history. Rotating is what removes the risk. Rewriting history is cleanup, it is genuinely awkward, and it does nothing about a key somebody already copied.

Then check that .gitignore actually contains .env, and add .env.local, .env.production and anything else your framework reads. One entry rarely covers every file a modern framework looks for.

6. Webhook with no signature check

If you take payments, you have a webhook. Stripe or Paddle or Dodo POSTs to it saying a payment succeeded, and your code upgrades the account.

Anyone who finds that URL can POST the same thing. Your handler has no way to tell your payment provider apart from a stranger with curl, unless you give it one.

Check: open your webhook handler and read the first ten lines. If it parses the body and acts on it before verifying a signature, it is an open door.

A failure looks like:

// Anyone can send this.
const event = JSON.parse(req.body);
if (event.type === 'checkout.session.completed') {
  await upgradeUser(event.data.object.customer_email);
}

What correct looks like: the signature is verified against the raw request body, before parsing, and the handler returns 400 when it fails. Your payment provider documents the exact call. There is one further detail that catches people: most frameworks parse JSON bodies automatically, and signature verification needs the raw bytes. If you verify against the re-serialised object, the check fails on valid requests and you will be tempted to remove it.

Verify the webhook signature against the raw request body before parsing or acting on any event. Configure this route to receive the raw body rather than parsed JSON. Return 400 on a signature failure. Show me the current handler and the corrected version.

Which of these matter most?

One and four, by a distance. If you only have time for two of these, do those two.

They are the two that produce the breaches you read about, and Moltbook was both at once: a publishable key in client JavaScript, which is fine, combined with RLS switched off, which is not. Either alone is survivable. Together they are a public database.

Four is the same failure at a different layer. A login page that only exists in the browser is a sign on a door that is not locked.

Two, three, five and six are real and worth an evening. Start with one and four tonight.

What a checklist cannot do

A checklist finds what it lists, and only where you point it. That is not a criticism of checklists so much as a description of what they are.

These six are the common ones precisely because they are the ones people know to look for. The flaw that gets exploited is usually somewhere nobody thought to look: the second admin route, the export endpoint added for one customer, the view that quietly bypasses the policies you spent an afternoon writing.

That is the honest argument for having something read the whole repository instead. Sentrint does that, in plain English, with a fix written for whichever tool built your app. A code scan, not a pentest: it reads what you wrote, it does not attack your running site. The first one is free and needs no card.

But run the six first. Fifteen minutes, no signup, and you will know more about your own app than you did this morning.


The Moltbook details in this article come from Wiz Research's writeup, published 2 February 2026. The issue was disclosed on 31 January and fully fixed within about three hours. The same misconfiguration was independently found by researcher Jameson O'Reilly and reported by 404 Media.