Rescue Audit← What you get

This is what a $400 audit report looks like. Real app, real findings, real fixes. Names changed.

Rescue Audit: sample report

Names and identifiers are changed. Findings, code, and fixes are real.

The short version

Not safe to promote yet, and not far off. Two edge functions are callable by anyone on the internet: one spends your OpenAI credits, the other is an open relay into your automation webhook. Both are fixed below. Seven of your eight database tables were created outside migrations, so their access rules can't be verified from the code and need a five-minute check in the Supabase dashboard. Your customer table, by contrast, is locked down properly.

Do these first

  1. Deploy the fixed blog-ai-assistant function (fix included below). Until then, anyone can run up your OpenAI bill.
  2. Deploy the fixed n8n-proxy function (fix included below). Until then, anyone can fire your lead automation with fake data.
  3. Open Supabase, Table Editor, and check the seven tables listed under H1 for the shield icon. Any table without it is readable by every visitor. candidatos_cuidadores_rows holds applicant personal data, so start there.
  4. Confirm the is_admin() helper is SECURITY DEFINER and reads a table users can't edit. Two minutes, prevents a normal user from making themselves admin.
  5. Move the anon key out of ChatbotWidget.tsx into the shared client. Cosmetic, but it stops a future copy-paste of the wrong key.

What we found

#SeverityIssueStatus
1Highblog-ai-assistant spends the OpenAI key with no caller checkFixed (PR #1)
2Mediumn8n-proxy relays any request to the automation webhook, no caller check, open CORSFixed (PR #2)
3High, verify7 tables exist only in the dashboard; RLS state unknown from the repoNeeds dashboard check
4MediumAdmin checks live in client code; server-side helper exists but not verifiedNeeds dashboard check
5InfoAnon key hardcoded in two client filesOptional cleanup
6LowNo .env.exampleOptional cleanup

What we checked and found correct: the customer table has row-level security on with owner-or-admin policies, the older wide-open policies were properly dropped in a later migration, no secrets are committed, no service_role key appears anywhere in client code, and no Stripe is wired up yet.

Finding 1: anyone can spend your OpenAI credits

supabase/functions/blog-ai-assistant/index.ts reads OPENAI_API_KEY from your secrets and calls OpenAI for whoever posts to it. It never looks at who is calling. The function is gated by Supabase's anon key, but that key is in your JavaScript bundle, so "gated" means "public."

Impact: a free GPT endpoint on your bill. At the "generate" action, which asks for 1,000-word articles, a script could burn through a month of credit in an afternoon.

Fix (PR #1): verify the caller's session before doing anything, and reject anonymous calls.

// at the top of Deno.serve, before reading the body
const authHeader = req.headers.get("Authorization") ?? "";
const jwt = authHeader.replace(/^Bearer\s+/i, "");
const supabase = createClient(
  Deno.env.get("SUPABASE_URL")!,
  Deno.env.get("SUPABASE_ANON_KEY")!,
  { global: { headers: { Authorization: authHeader } } },
);
const { data: { user }, error } = await supabase.auth.getUser(jwt);
if (error || !user) {
  return new Response(JSON.stringify({ error: "Sign in required." }),
    { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } });
}
// optional: only editors may use the "generate" action

Also restrict CORS to your domain once auth is in:

"Access-Control-Allow-Origin": "https://www.example.com",

Finding 2: your automation webhook is an open relay

supabase/functions/n8n-proxy/index.ts takes any POST body and forwards it to a hardcoded n8n webhook. No caller check, Access-Control-Allow-Origin: *. Whatever that webhook does, likely creating a lead and sending a WhatsApp message, anyone can trigger with any payload, from any website.

Impact: spam leads, fake applicants, and a way to make your automation send messages you didn't write.

Fix (PR #2): require a shared secret that only your site knows, validate the body shape, and move the webhook URL to a secret.

const N8N_WEBHOOK_URL = Deno.env.get("N8N_WEBHOOK_URL")!;
const RELAY_SECRET = Deno.env.get("RELAY_SECRET")!;

if (req.headers.get("x-relay-secret") !== RELAY_SECRET) {
  return new Response(JSON.stringify({ error: "Forbidden" }),
    { status: 403, headers: { ...corsHeaders, "Content-Type": "application/json" } });
}
const body = await req.json();
const allowed = ["name", "phone", "email", "message", "city"];
const clean = Object.fromEntries(
  Object.entries(body).filter(([k, v]) => allowed.includes(k) && typeof v === "string" && v.length < 2000),
);
if (!clean.name || !clean.phone) {
  return new Response(JSON.stringify({ error: "name and phone are required" }),
    { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } });
}
// forward `clean`, not `body`

A shared secret in a browser bundle is not a strong secret, but it stops drive-by abuse from other sites and scripts. If the form is only used by signed-in users, use the session check from Finding 1 instead.

Finding 3: seven tables you can't audit from the code

Your generated types.ts lists eight tables. Your migrations create one. The other seven, blog_posts, candidatos_cuidadores_rows, caregivers, content_items, partners, profiles, testimonials, were created in the Supabase dashboard or by the builder without a migration file.

That means nobody can tell from the repository whether they have row-level security on, or what their policies say. Your July migrations do reference admin policies on several of them, which is a good sign, but the base state is unknown.

What to do: Supabase, Table Editor, look at each table. A shield icon means RLS is on. Then Authentication, Policies, and confirm each table has at least one policy scoped to the user or to is_admin(). Then run the second-account test from the checklist. Start with candidatos_cuidadores_rows, it holds applicant personal data.

We'd normally do this with a read-only dashboard invite, and it's covered by the audit.

Finding 4: confirm the admin helper is safe

Client code checks isAdmin to show or hide admin pages. That's fine for the UI. What matters is the server: your migrations reference an is_admin() function in policies, which is the right pattern. Two things to confirm: that it's declared SECURITY DEFINER, and that it reads a role from a table normal users cannot update. If a user can edit their own profiles.role, the helper is decorative.

What's left, and what it costs

Findings 1 and 2 are fixed as part of this audit. Findings 3 and 4 need dashboard access and about an hour, which is also included if you send a read-only invite. Nothing here needs a bigger project.

If you'd like ongoing coverage, that's $500 a month: fixes, small changes, and a monthly check. Cancel any time.

Questions

Reply by email, any time in the next seven days. Answers in writing, usually the same day.

Want this for your app?
$400, 48 hours, two fixes included, all in writing.
Book the audit