Sample · not a client
Sample report on our demo app — not a client. turnos-pro is a fictional booking SaaS we built with Lovable + Supabase to test our process. Every number below comes from real test runs on it.

Round 1 + fix verification

Production Readiness Report

App
turnos-pro · booking SaaS for clinics and salons (demo)
Stack
Lovable · React + Vite · Supabase (Postgres, Auth, Storage, Edge Functions) · Mercado Pago
Date
13 October 2026
Method
OWASP ASVS 5.0 · code review + automated attack tests

Founder view

Verdict
NOT READYREADY FOR FINAL GO-LIVE CHECKS

As delivered, the app should not launch: 6 critical issues expose all customer data and allow fake payments. With the fixes in this report applied (already written and tested) and the manual steps under Next 24 hours done, every control we ran passes. What remains before launch are the go-live checks this review did not cover: a live payment test, Supabase Auth settings, backups, hosting and dependencies (see Scope).

53controls run38 database · 9 edge function · 6 code
11pass before fixes
53pass after fixes
14findings6 critical · 3 high · 5 medium

What the controls show

Attacks blocked39 attack attempts run as anonymous visitors, outsiders and other businesses
Before
0/39
After
39/39
Normal use still works14 checks that legitimate users keep doing their job
Before
11/14
After
14/14

Founder summary

What blocks launch?

Six critical issues, all fixed in this report: the master database key is inside the website (F-01); anyone can join any business as owner (F-02) or make himself admin (F-03); payments can be faked with one request (F-04); any business's agenda and every client list can be read by strangers (F-05, F-06).

What could expose customer data?

Before the fixes: client names, emails, phones and private notes including health data (F-06), client phones through the agenda without login (F-05), ID cards and medical documents in storage (F-08), and every user's email and phone (F-10). After the fixes, each of these attacks was re-run and returned nothing.

Is payment safe enough to launch?

Not before: one forged request marked a booking as paid (control C39), the customer chose the price (C42), and real Mercado Pago notifications were not processed at all (C40). After the fixes: unsigned requests are refused, amounts are confirmed with Mercado Pago and must match the booking price, and replays are recorded once. Condition: set the two Mercado Pago secrets and complete one sandbox payment end to end before going live — that live test is outside this review.

Next 24 hours

  1. Rotate the service_role key in Supabase and purge .env from git history (F-01).
  2. Apply migration 0005_security_fixes.sql and deploy the four updated edge functions.
  3. Set function secrets: MP_WEBHOOK_SECRET, MP_ACCESS_TOKEN, ALLOWED_ORIGINS.
  4. Check production data: unexpected rows in staff, accounts with is_admin = true, overlapping active bookings.

Next 7 days

  1. Run one end-to-end payment in Mercado Pago's sandbox and confirm the booking flips to confirmed.
  2. Add tests/run-controls.sh to CI so every Lovable change is re-tested before deploy.
  3. Deactivate coupon AMIGO50 if it should not stay public.
  4. Decide the public booking flow: today an anonymous visitor cannot load /book/:slug (businesses are readable only by members), so customers cannot book without being staff. A functional gap, not a security one.

Findings at a glance

IDSeverityWhat it means for youStatus
F-01 Critical The master database key is published inside the websiteAnyone who opens the site can copy the key that skips every security rule and read, change or delete all data of every business. FixedC48, C46
F-02 Critical Any signed-up user can add himself as owner of any businessA free account is enough to join any clinic or salon as staff and then see and change its agenda, clients, payments, prices and documents. FixedC07
F-03 Critical Any user can make himself administratorOne request turns a normal account into an admin. Today that unlocks the admin menu; every admin feature built later would be open to everyone. FixedC01, C02
F-04 Critical Payments can be faked: the payment webhook believes any requestAnyone can send one request that marks a booking as paid and confirmed without paying, or fills a business's revenue with invented payments. FixedC39, C40, C30, C31
F-05 Critical Any business's agenda can be downloaded without logging inWithout an account, anyone can pull every appointment of any business, with each client's name, phone, service and price. FixedC12, C13
F-06 Critical Every client of every business is visible to any logged-in userAnyone who signs up can download the full client list of every clinic and salon: name, email, phone and private notes, which can include health data. FixedC09, C10
F-07 High Anyone, even without logging in, can cancel or confirm any appointmentA stranger can cancel a business's whole day or mark unpaid bookings as confirmed. Appointment ids are exposed by F-05. FixedC15, C16, C17
F-08 High Client ID documents can be read and uploaded across businessesAny logged-in user can list and download the ID cards, medical studies and consent forms of every business's clients, and drop files into other businesses' folders. FixedC19, C21, C28
F-09 High The customer decides the price, and discount codes are visible in the websiteA booking can be sent with any price (for example 1 cent). The 50% code AMIGO50 and the other coupons can be read by anyone in the page source. FixedC42, C32, C51
F-10 Medium Every user's email and phone can be listed without logging inAnyone on the internet can download the list of registered users with email, phone and who is admin: material for spam, phishing or targeted attacks. FixedC04
F-11 Medium Anyone can host files in the public marketing bucketAny account can upload a phishing page or malware that is then served from your project's public storage address, with no size or type limit. FixedC24, C29
F-12 Medium Double bookings and bookings in the past are possibleTwo customers can get the same slot, and the booking page offers times that already passed. Annoying for customers, costly for the business. FixedC35, C36
F-13 Medium Error responses reveal internal detailsA malformed request returns stack traces and raw database messages that help an attacker map the system. FixedC41, C43
F-14 Medium Any website can call the functions from a visitor's browserThe functions accept cross-origin calls from any website, and one of them also declares it accepts credentials, an invalid and unsafe combination. FixedC44

Technical findings

F-01 Critical

The master database key is published inside the website

Fixed

Impact. Anyone who opens the site can copy the key that skips every security rule and read, change or delete all data of every business.

Location
src/integrations/supabase/admin.ts · .env (VITE_SUPABASE_SERVICE_ROLE_KEY) · src/pages/AdminPage.tsx
Standard
V13 Configuration (secret management) · V8 Authorization

What is wrong

The admin panel creates a Supabase client with the service_role key read from a VITE_ variable. Vite inlines every VITE_ variable into the public JavaScript bundle, so the key ships to every visitor. The .env file holding it is committed. The /admin route has no server-side check; the menu link is only hidden.

Evidence (real test output)

Production build of the original app (npx vite build), then searching the output:
$ grep -o 'eyJ[A-Za-z0-9._-]*service_role_key_full_access' dist/assets/*.js
dist/assets/index-D4vCh4Ci.js: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIi...
# JWT payload decodes to {"role":"service_role"}. Same search on the fixed build: no match.

C48 No service_role key in frontend code or VITE_ variables; .env is git-ignored

beforeFAILservice_role referenced in: src/integrations/supabase/admin.ts, .env; .env not in .gitignore
afterPASSno references; .env ignored

C46 Admin data refused to a non-admin user (server-side check)

beforeFAILno server-side admin endpoint: the admin panel queries the database from the browser with the service_role key
afterPASSHTTP 403 {"error":"forbidden"}

Reproduction

# 1. Open the live site, DevTools > Sources, search the main .js file for "service_role"
# 2. Use the key against the REST API — every row of every table comes back:
curl "https://<project>.supabase.co/rest/v1/clients?select=*" \
  -H "apikey: <service_role key from the bundle>" \
  -H "Authorization: Bearer <service_role key from the bundle>"

Expected behavior

The service_role key exists only on the server. Admin data is served by a server function that verifies the caller is an admin (a flag users cannot edit).

Fix (diff excerpt)

- src/integrations/supabase/admin.ts          (deleted)
- VITE_SUPABASE_SERVICE_ROLE_KEY=eyJ...        (.env, removed; .env added to .gitignore)
--- a/src/pages/AdminPage.tsx
-      const { data } = await adminClient.from("businesses").select("id, name, slug, owner_id");
+      const { data, error } = await supabase.functions.invoke("admin-overview");
+++ b/supabase/functions/admin-overview/index.ts  (new)
+  const { data: { user } } = await admin.auth.getUser(token);
+  if (!user) return json({ error: "unauthorized" }, 401);
+  const { data: profile } = await admin.from("profiles").select("is_admin").eq("id", user.id).single();
+  if (!profile?.is_admin) return json({ error: "forbidden" }, 403);
Also required (manual)

Rotate the service_role key in Supabase (Settings > API) and purge .env from git history. Code cannot do this for you: the old key must be treated as leaked.

Controls for this finding

ControlBeforeAfter
C48 No service_role key in frontend code or VITE_ variables; .env is git-ignored FAIL PASS
C46 Admin data refused to a non-admin user (server-side check) FAIL PASS
C47 Admin data served to a real admin through the server normal use FAIL PASS
F-02 Critical

Any signed-up user can add himself as owner of any business

Fixed

Impact. A free account is enough to join any clinic or salon as staff and then see and change its agenda, clients, payments, prices and documents.

Location
supabase/migrations/0002_rls.sql · policy "staff can be added" on public.staff
Standard
V8 Authorization

What is wrong

The INSERT policy on staff is WITH CHECK (true). Every other permission in the app is "is this user in staff for this business?", so whoever can write to staff holds the keys to every tenant. Business IDs are readable without login from the public services table.

Evidence (real test output)

C07 An outsider cannot add himself as owner of another business

beforeFAILaffected=1; state after = 1
afterPASSERROR: new row violates row-level security policy for table "staff"

Reproduction

-- as any logged-in user (role authenticated, own JWT)
insert into public.staff (business_id, user_id, role, display_name)
values ('<business A id>', auth.uid(), 'owner', 'x');

Expected behavior

Only the owner of a business can add staff to it.

Fix (diff excerpt)

-create policy "staff can be added" on public.staff
-  for insert to authenticated
-  with check (true);
+create policy "owners add staff" on public.staff
+  for insert to authenticated
+  with check (public.is_business_owner(business_id));
Also required (manual)

Review the staff table in production and remove memberships nobody approved.

Controls for this finding

ControlBeforeAfter
C07 An outsider cannot add himself as owner of another business FAIL PASS
C08 The owner can add staff to his own business normal use PASS PASS
F-03 Critical

Any user can make himself administrator

Fixed

Impact. One request turns a normal account into an admin. Today that unlocks the admin menu; every admin feature built later would be open to everyone.

Location
supabase/migrations/0002_rls.sql · policy "users update own profile" · src/components/Layout.tsx
Standard
V8 Authorization (field-level access)

What is wrong

The profile UPDATE policy correctly limits users to their own row, but not to specific columns. is_admin and role live in the same row, so users can rewrite them.

Evidence (real test output)

C01 A user cannot give himself is_admin = true

beforeFAILaffected=1; state after = true
afterPASSERROR: permission denied for table profiles

C02 A user cannot change his own role to admin

beforeFAILaffected=1; state after = admin
afterPASSERROR: permission denied for table profiles

Reproduction

-- as any logged-in user
update public.profiles set is_admin = true, role = 'admin' where id = auth.uid();

Expected behavior

Users can edit their name and phone; role and is_admin change only from the server.

Fix (diff excerpt)

+revoke insert, update on public.profiles from anon, authenticated;
+grant update (full_name, phone) on public.profiles to authenticated;
Also required (manual)

Check which accounts currently have is_admin = true or role = 'admin' in production.

Controls for this finding

ControlBeforeAfter
C01 A user cannot give himself is_admin = true FAIL PASS
C02 A user cannot change his own role to admin FAIL PASS
C03 A user can still edit his own name and phone normal use PASS PASS
F-04 Critical

Payments can be faked: the payment webhook believes any request

Fixed

Impact. Anyone can send one request that marks a booking as paid and confirmed without paying, or fills a business's revenue with invented payments.

Location
supabase/functions/payments-webhook/index.ts · 0002_rls.sql policy "members insert payments"
Standard
V2 Validation and Business Logic · V4 API and Web Service

What is wrong

The webhook runs with the service_role key and takes status, amount, appointment and business from the request body without checking Mercado Pago's signature or asking Mercado Pago about the payment. If no status is sent it assumes "approved". Separately, staff could insert "paid" rows straight from the browser, and nothing stopped the same payment being recorded twice. The run also showed the opposite problem: a real Mercado Pago notification (which only carries the payment id) was rejected with "missing appointment", so genuine payments never confirmed bookings.

Evidence (real test output)

C39 A forged, unsigned 'payment approved' call changes nothing

beforeFAILHTTP 200; database writes: POST /rest/v1/payments {"appointment_id":"a1000000-0000-0000-0000-000000000001","business_id":"aaaaaaaa-0000-0000-0000-000000000001","amount_cents":100,"status":"paid","provider":"mp","provider_ref":""} || PATCH /rest/v1/appointments {"status":"confirmed"}
afterPASSHTTP 401; database writes: none

C40 A genuine signed notification (confirmed with Mercado Pago) marks the booking paidnormal use

beforeFAILHTTP 400 {"error":"missing appointment"}; database writes: none
afterPASSHTTP 200 {"ok":true}; database writes: POST /rest/v1/payments {"appointment_id":"a1000000-0000-0000-0000-000000000001","business_id":"aaaaaaaa-0000-0000-0000-000000000001","amount_cents":500000,"currency":"ARS","status":"paid","provider":"mp","provider_ref":" || PATCH /rest/v1/appointments {"status":"confirmed"}

C30 Nobody can write a "paid" payment from the browser (only the verified webhook)

beforeFAILaffected=1; state after = 1
afterPASSERROR: new row violates row-level security policy for table "payments"

C31 A replayed webhook cannot record the same payment twice

beforeFAILaffected=2; state after = 2
afterPASSERROR: duplicate key value violates unique constraint "payments_provider_ref_unique"

Reproduction

curl -X POST "https://<project>.supabase.co/functions/v1/payments-webhook" \
  -H "Content-Type: application/json" \
  -d '{"metadata":{"appointment_id":"<id>","business_id":"<id>"},"amount":1}'
# -> 200 {"ok":true}; payment stored as "paid", appointment set to "confirmed"

Expected behavior

Unsigned requests get 401 and change nothing. Status and amount come from Mercado Pago's API, the amount must equal the booking price, and a payment id is recorded once.

Fix (diff excerpt)

-    const status = event.data?.status ?? event.status ?? "approved";
-    const amount = event.data?.transaction_amount ?? event.amount ?? 0;
+    const valid = await verifyMpSignature({
+      xSignature: req.headers.get("x-signature"),
+      xRequestId: req.headers.get("x-request-id"),
+      dataId, secret: Deno.env.get("MP_WEBHOOK_SECRET") ?? "" });
+    if (!valid) return json({ error: "invalid_signature" }, 401);
+    const mpRes = await fetch(`https://api.mercadopago.com/v1/payments/${encodeURIComponent(dataId!)}`, ...);
+    const paid = payment.status === "approved" && amountCents === appt.price_cents;
+    await supabase.from("payments").upsert({ ... }, { onConflict: "provider,provider_ref" });
-- migration 0005
+drop policy if exists "members insert payments" on public.payments;
+alter table public.payments add constraint payments_provider_ref_unique unique (provider, provider_ref);
Also required (manual)

Set MP_WEBHOOK_SECRET and MP_ACCESS_TOKEN as function secrets and run one end-to-end payment in Mercado Pago's sandbox before launch.

Controls for this finding

ControlBeforeAfter
C39 A forged, unsigned 'payment approved' call changes nothing FAIL PASS
C40 A genuine signed notification (confirmed with Mercado Pago) marks the booking paid normal use FAIL PASS
C49 Signature verifier accepts genuine webhooks and rejects forged/replayed ones (executed) FAIL PASS
C50 Payment status/amount are read from Mercado Pago and checked against the booking price FAIL PASS
C30 Nobody can write a "paid" payment from the browser (only the verified webhook) FAIL PASS
C31 A replayed webhook cannot record the same payment twice FAIL PASS
F-05 Critical

Any business's agenda can be downloaded without logging in

Fixed

Impact. Without an account, anyone can pull every appointment of any business, with each client's name, phone, service and price.

Location
supabase/migrations/0003_functions.sql · function public.get_business_appointments
Standard
V8 Authorization

What is wrong

The function is SECURITY DEFINER (it skips row-level security) and filters only by the business id it receives, without checking that the caller belongs to that business. It is executable by anon. Free scanners do not flag it because search_path is set.

Evidence (real test output)

C12 A visitor without login cannot pull a business's agenda

beforeFAILrows=1: ("Patient A","+54 11 4444-0001",500000)
afterPASSERROR: permission denied for function get_business_appointments

C13 Business B cannot pull Business A's agenda

beforeFAILrows=1: ("Patient A","+54 11 4444-0001",500000)
afterPASSERROR: forbidden

Reproduction

curl "https://<project>.supabase.co/rest/v1/rpc/get_business_appointments" \
  -H "apikey: <public anon key>" -H "Content-Type: application/json" \
  -d '{"p_business_id":"<business A id>"}'

Expected behavior

Only members of the business can read its agenda; anonymous calls are refused.

Fix (diff excerpt)

+  if not public.is_business_member(p_business_id) then
+    raise exception 'forbidden' using errcode = '42501';
+  end if;
+revoke execute on function public.get_business_appointments(uuid, timestamptz, timestamptz) from public, anon;

Controls for this finding

ControlBeforeAfter
C12 A visitor without login cannot pull a business's agenda FAIL PASS
C13 Business B cannot pull Business A's agenda FAIL PASS
C14 Business A can load its own agenda normal use PASS PASS
F-06 Critical

Every client of every business is visible to any logged-in user

Fixed

Impact. Anyone who signs up can download the full client list of every clinic and salon: name, email, phone and private notes, which can include health data.

Location
supabase/migrations/0002_rls.sql · policy "read clients" on public.clients
Standard
V8 Authorization · V14 Data Protection

What is wrong

The SELECT policy is USING (auth.role() = 'authenticated'), which is true for every logged-in user. The other client policies correctly check business membership; this one does not.

Evidence (real test output)

C09 An outsider cannot read any business's clients

beforeFAILrows=2: ("Patient A",[email protected],"+54 11 4444-0001","allergic to penicillin") | ("Customer B",[email protected],"+54 11 4444-0002","prefers mornings")
afterPASSrows=0

C10 Business B cannot read Business A's clients

beforeFAILrows=1: ("Patient A","+54 11 4444-0001","allergic to penicillin")
afterPASSrows=0

Reproduction

-- as any logged-in user
select full_name, email, phone, notes from public.clients;

Expected behavior

Users see only the clients of businesses they belong to.

Fix (diff excerpt)

-create policy "read clients" on public.clients
-  for select to authenticated
-  using (auth.role() = 'authenticated');
+create policy "members read clients" on public.clients
+  for select to authenticated
+  using (public.is_business_member(business_id));

Controls for this finding

ControlBeforeAfter
C09 An outsider cannot read any business's clients FAIL PASS
C10 Business B cannot read Business A's clients FAIL PASS
C11 Business A can read its own clients normal use PASS PASS
F-07 High

Anyone, even without logging in, can cancel or confirm any appointment

Fixed

Impact. A stranger can cancel a business's whole day or mark unpaid bookings as confirmed. Appointment ids are exposed by F-05.

Location
supabase/migrations/0003_functions.sql · function public.update_appointment_status
Standard
V8 Authorization · V2 Validation and Business Logic

What is wrong

SECURITY DEFINER function that updates any appointment by id, with no membership check and no validation of the new status value. Executable by anon.

Evidence (real test output)

C15 A visitor without login cannot cancel an appointment

beforeFAILaffected=1; state after = cancelled
afterPASSERROR: permission denied for function update_appointment_status

C16 Business B cannot confirm Business A's appointment

beforeFAILaffected=1; state after = confirmed
afterPASSERROR: forbidden

C17 Status only accepts known values

beforeFAILaffected=1; state after = hacked
afterPASSERROR: invalid status

Reproduction

-- as anon (no login)
select public.update_appointment_status('<appointment id>', 'cancelled');

Expected behavior

Only members of the appointment's business can change it, and only to a known status.

Fix (diff excerpt)

+  if p_status is null or p_status not in ('pending','confirmed','cancelled','done','no_show') then
+    raise exception 'invalid status' using errcode = '22023';
+  end if;
+  select a.business_id into v_business from public.appointments a where a.id = p_appointment_id;
+  if v_business is null or not public.is_business_member(v_business) then
+    raise exception 'forbidden' using errcode = '42501';
+  end if;
+revoke execute on function public.update_appointment_status(uuid, text) from public, anon;

Controls for this finding

ControlBeforeAfter
C15 A visitor without login cannot cancel an appointment FAIL PASS
C16 Business B cannot confirm Business A's appointment FAIL PASS
C17 Status only accepts known values FAIL PASS
C18 Business A can cancel its own appointment normal use PASS PASS
F-08 High

Client ID documents can be read and uploaded across businesses

Fixed

Impact. Any logged-in user can list and download the ID cards, medical studies and consent forms of every business's clients, and drop files into other businesses' folders.

Location
supabase/migrations/0004_storage.sql · policies on storage.objects (bucket client-docs)
Standard
V5 File Handling · V8 Authorization

What is wrong

The storage policies only check bucket_id = 'client-docs', not the business folder in the path (<business_id>/<client_id>/file). The bucket has no size limit and accepts any file type.

Evidence (real test output)

C19 An outsider cannot list client ID documents

beforeFAILrows=2: (aaaaaaaa-0000-0000-0000-000000000001/ca000000-0000-0000-0000-000000000001/id-card.pdf) | (bbbbbbbb-0000-0000-0000-000000000002/cb000000-0000-0000-0000-000000000002/consent.pdf)
afterPASSrows=0

C21 An outsider cannot upload into Business A's folder

beforeFAILaffected=1; state after = 1
afterPASSERROR: new row violates row-level security policy for table "objects"

C28 client-docs bucket has a size limit and a type allowlist (no HTML/SVG)

beforeFAILsize_limit=none; types=any
afterPASSsize_limit=10485760; types={application/pdf,image/jpeg,image/png}

Reproduction

-- as any logged-in user
select name from storage.objects where bucket_id = 'client-docs';
-- with the listed path, supabase.storage.from('client-docs').createSignedUrl(path, 60) downloads it

Expected behavior

Only members of the business in the first folder of the path can list, upload or delete; uploads are limited to PDF/JPEG/PNG up to 10 MB.

Fix (diff excerpt)

-create policy "read client docs" on storage.objects
-  for select to authenticated using (bucket_id = 'client-docs');
+create policy "members read client docs" on storage.objects
+  for select to authenticated
+  using (bucket_id = 'client-docs' and public.is_member_of_folder(name));
+update storage.buckets set file_size_limit = 10485760,
+  allowed_mime_types = array['application/pdf','image/jpeg','image/png'] where id = 'client-docs';

Controls for this finding

ControlBeforeAfter
C19 An outsider cannot list client ID documents FAIL PASS
C20 Business B cannot list Business A's documents FAIL PASS
C21 An outsider cannot upload into Business A's folder FAIL PASS
C22 Business A can upload to its own folder normal use PASS PASS
C23 Business A sees only its own documents, not Business B's FAIL PASS
C28 client-docs bucket has a size limit and a type allowlist (no HTML/SVG) FAIL PASS
F-09 High

The customer decides the price, and discount codes are visible in the website

Fixed

Impact. A booking can be sent with any price (for example 1 cent). The 50% code AMIGO50 and the other coupons can be read by anyone in the page source.

Location
supabase/functions/create-booking/index.ts · src/lib/pricing.ts · src/pages/BookingPage.tsx
Standard
V2 Validation and Business Logic

What is wrong

The browser computes the total (coupons included) and create-booking stores price_cents and business_id exactly as sent. Row-level security still blocks inserts from non-members in the original app, so the price is forged by staff accounts today, and by anyone combined with F-02.

Evidence (real test output)

C42 A customer who sends price_cents = 1 is still charged the real price (500000)

beforeFAILHTTP 200; stored row: price_cents=1, business_id=attacker-chosen business
afterPASSHTTP 200; stored row: price_cents=500000, business_id=service's business

C32 Server quotes the real price; an invented coupon gives no discount

beforeFAILERROR: function public.quote_price(unknown, unknown) does not exist
afterPASSrows=1: (500000)

C51 No coupon codes in frontend source

beforeFAILsrc/lib/pricing.ts: "BIENVENIDA10", "VERANO25", "AMIGO50"
afterPASSnone found

Reproduction

curl -X POST "https://<project>.supabase.co/functions/v1/create-booking" \
  -H "Authorization: Bearer <user JWT>" -H "Content-Type: application/json" \
  -d '{"service_id":"<id>","business_id":"<other id>","starts_at":"...","ends_at":"...","price_cents":1}'

Expected behavior

Price, duration and business come from the database; coupons are validated on the server against a table users cannot read.

Fix (diff excerpt)

-      business_id: body.business_id,
-      price_cents: body.price_cents,
+    const { data: service } = await supabase.from("services")
+      .select("id, business_id, duration_min").eq("id", body.service_id).eq("active", true).single();
+    const { data: price } = await supabase.rpc("quote_price", { p_service_id: service.id, p_coupon: body.coupon });
+        business_id: service.business_id,
+        price_cents: price,
-- migration 0005: table public.coupons (RLS on, no client access) + function public.quote_price()
Also required (manual)

AMIGO50 was public in the old bundle: deactivate it in public.coupons if it should not be used by everyone.

Controls for this finding

ControlBeforeAfter
C42 A customer who sends price_cents = 1 is still charged the real price (500000) FAIL PASS
C32 Server quotes the real price; an invented coupon gives no discount FAIL PASS
C33 A valid coupon is applied on the server normal use FAIL PASS
C34 Coupon codes cannot be listed by users FAIL PASS
C51 No coupon codes in frontend source FAIL PASS
F-10 Medium

Every user's email and phone can be listed without logging in

Fixed

Impact. Anyone on the internet can download the list of registered users with email, phone and who is admin: material for spam, phishing or targeted attacks.

Location
supabase/migrations/0002_rls.sql · policy "profiles are viewable"
Standard
V8 Authorization · V14 Data Protection

What is wrong

SELECT policy USING (true) with no role restriction, so it also applies to anon.

Evidence (real test output)

C04 A visitor without login cannot list user emails and phones

beforeFAILrows=4: ([email protected],"+54 11 5555-0a",f) | ([email protected],"+54 11 5555-0b",f) | ([email protected],"+54 11 5555-0c",f) | ([email protected],"+54 11 5555-0d",f)
afterPASSrows=0

Reproduction

curl "https://<project>.supabase.co/rest/v1/profiles?select=email,phone,is_admin" -H "apikey: <public anon key>"

Expected behavior

Each user reads only his own profile.

Fix (diff excerpt)

-create policy "profiles are viewable" on public.profiles
-  for select using (true);
+create policy "users read own profile" on public.profiles
+  for select to authenticated using (id = auth.uid());

Controls for this finding

ControlBeforeAfter
C04 A visitor without login cannot list user emails and phones FAIL PASS
C05 A logged-in user cannot read other users' profiles FAIL PASS
C06 A user can read his own profile normal use PASS PASS
F-11 Medium

Anyone can host files in the public marketing bucket

Fixed

Impact. Any account can upload a phishing page or malware that is then served from your project's public storage address, with no size or type limit.

Location
supabase/migrations/0004_storage.sql · bucket marketing · policy "members upload marketing"
Standard
V5 File Handling

What is wrong

Despite its name, the upload policy only checks bucket_id = 'marketing'. The bucket is public (by design, for logos and banners) with no size limit or type allowlist.

Evidence (real test output)

C24 An outsider cannot host files (e.g. a phishing page) in the public bucket

beforeFAILaffected=1; state after = 1
afterPASSERROR: new row violates row-level security policy for table "objects"

C29 marketing bucket has a size limit and an image-only allowlist

beforeFAILsize_limit=none; types=any
afterPASSsize_limit=2097152; types={image/png,image/jpeg,image/webp}

Reproduction

-- as any logged-in user
insert into storage.objects (bucket_id, name, owner) values ('marketing', 'login-verify.html', auth.uid());

Expected behavior

Only business members upload, inside their own folder, images only, up to 2 MB. Public reading stays as designed.

Fix (diff excerpt)

+create policy "members upload marketing" on storage.objects
+  for insert to authenticated
+  with check (bucket_id = 'marketing' and public.is_member_of_folder(name));
+update storage.buckets set file_size_limit = 2097152,
+  allowed_mime_types = array['image/png','image/jpeg','image/webp'] where id = 'marketing';

Controls for this finding

ControlBeforeAfter
C24 An outsider cannot host files (e.g. a phishing page) in the public bucket FAIL PASS
C25 Business B cannot replace Business A's public images FAIL PASS
C26 Business A can upload its own banner normal use PASS PASS
C27 Marketing images stay public by design normal use PASS PASS
C29 marketing bucket has a size limit and an image-only allowlist FAIL PASS
F-12 Medium

Double bookings and bookings in the past are possible

Fixed

Impact. Two customers can get the same slot, and the booking page offers times that already passed. Annoying for customers, costly for the business.

Location
supabase/migrations/0003_functions.sql · get_available_slots · table public.appointments
Standard
V2 Validation and Business Logic

What is wrong

Availability only hides slots whose start time is exactly equal to an existing booking, and nothing in the database prevents overlaps.

Evidence (real test output)

C35 Two bookings cannot overlap for the same service

beforeFAILaffected=1; state after = 2
afterPASSERROR: conflicting key value violates exclusion constraint "appointments_no_overlap"

C36 Availability hides slots that overlap an existing booking (10:15-10:45)

beforeFAILrows=2: (10:00) | (10:30)
afterPASSrows=0

Reproduction

select to_char(slot,'HH24:MI') from public.get_available_slots('<service>', current_date + 7);
-- an existing booking 10:15-10:45 still leaves 10:00 and 10:30 on offer

Expected behavior

Overlapping bookings for the same service are rejected by the database; availability excludes overlaps and past times.

Fix (diff excerpt)

+alter table public.appointments add constraint appointments_no_overlap
+  exclude using gist (service_id with =, tstzrange(starts_at, ends_at, '[)') with &&)
+  where (status <> 'cancelled');
+  where g.slot > now()
+    and not exists (... tstzrange(a.starts_at, a.ends_at, '[)') && tstzrange(g.slot, g.slot + duration, '[)'))
Also required (manual)

The constraint will not apply if production already has overlapping active bookings: resolve those first.

Controls for this finding

ControlBeforeAfter
C35 Two bookings cannot overlap for the same service FAIL PASS
C36 Availability hides slots that overlap an existing booking (10:15-10:45) FAIL PASS
C37 Availability offers no slots in the past FAIL PASS
C38 Free future slots are still offered normal use PASS PASS
F-13 Medium

Error responses reveal internal details

Fixed

Impact. A malformed request returns stack traces and raw database messages that help an attacker map the system.

Location
payments-webhook/index.ts (catch) · create-booking/index.ts · get-availability/index.ts
Standard
V16 Security Logging and Error Handling

What is wrong

The webhook returns {error, stack}; the other functions return error.message from the database as is.

Evidence (real test output)

C41 Webhook errors do not reveal stack traces

beforeFAILHTTP 500; body: {"error":"SyntaxError: Expected property name or '}' in JSON at position 1 (line 1 column 2)","stack":"SyntaxError: Expected property name or '}' in JSON at position 1 (line 1 column 2)\n at parse
afterPASSHTTP 500; body: {"error":"internal_error"}

C43 Database error messages are not echoed to the browser

beforeFAILHTTP 400; body: {"error":"duplicate key value violates unique constraint \"appointments_internal_idx_7\""}
afterPASSHTTP 400; body: {"error":"booking_failed"}

Reproduction

curl -X POST "https://<project>.supabase.co/functions/v1/payments-webhook" -d '{not json'

Expected behavior

Generic error bodies to the client; details only in server logs.

Fix (diff excerpt)

-    return new Response(JSON.stringify({ error: String(err), stack: (err as Error).stack }), {
+    console.error("payments-webhook failed", err);  // details stay in server logs
+    return json({ error: "internal_error" }, 500);

Controls for this finding

ControlBeforeAfter
C41 Webhook errors do not reveal stack traces FAIL PASS
C43 Database error messages are not echoed to the browser FAIL PASS
C52 Edge functions return generic errors (no stack traces / raw DB messages) FAIL PASS
F-14 Medium

Any website can call the functions from a visitor's browser

Fixed

Impact. The functions accept cross-origin calls from any website, and one of them also declares it accepts credentials, an invalid and unsafe combination.

Location
get-availability/index.ts · create-booking/index.ts · payments-webhook/index.ts
Standard
V3 Web Frontend Security

What is wrong

Access-Control-Allow-Origin: * on every function; get-availability adds Access-Control-Allow-Credentials: true.

Evidence (real test output)

C44 A foreign website is not granted cross-origin access

beforeFAILOrigin evil.example -> Allow-Origin: *; Allow-Credentials: true
afterPASSOrigin evil.example -> Allow-Origin: (none); Allow-Credentials: (none)

Reproduction

curl -i -X OPTIONS "https://<project>.supabase.co/functions/v1/get-availability" -H "Origin: https://evil.example"

Expected behavior

Only the app's own origins (ALLOWED_ORIGINS) are allowed; never credentials with a wildcard. The webhook sends no CORS headers (server-to-server).

Fix (diff excerpt)

-  "Access-Control-Allow-Origin": "*",
-  "Access-Control-Allow-Credentials": "true",
+// supabase/functions/_shared/cors.ts
+  if (ALLOWED_ORIGINS.includes(origin)) headers["Access-Control-Allow-Origin"] = origin;
Also required (manual)

Set ALLOWED_ORIGINS to the production domain(s) in the function secrets.

Controls for this finding

ControlBeforeAfter
C44 A foreign website is not granted cross-origin access FAIL PASS
C45 The app's own origin can still call the function normal use PASS PASS
C53 Edge functions use an origin allowlist (no '*', never with credentials) FAIL PASS

Scope and what was not tested

Covered

  • All SQL migrations (tables, row-level security, functions, storage policies and buckets), tested on a real PostgreSQL 16 database with a Supabase-compatible layer (anon / authenticated / service_role roles, JWT claims, auth.uid()).
  • The four edge functions, executed in Deno with the real code and mocked Supabase and Mercado Pago endpoints, plus a type check (deno check).
  • Frontend source and production bundle (vite build, tsc) for secrets, client-side pricing and access control.

Not covered

  • No penetration test against a live deployment: the app was never deployed; the Supabase layer is an emulation, not the hosted service.
  • Mercado Pago signature format implemented from Mercado Pago's documented scheme and tested with locally generated HMACs, not with a real notification.
  • File size and type limits are enforced by Supabase Storage's API; here they were verified as bucket configuration only.
  • No load or performance testing, no rate limiting review, no denial-of-service testing.
  • No review of Supabase Auth settings (email confirmation, password policy, MFA), DNS, hosting or backups.
  • No dependency/CVE audit of npm packages.
  • Not a compliance assessment (HIPAA, GDPR, SOC 2, ISO). The demo stores health-related notes; we tested who can technically read them, not regulatory compliance.

Controls left in your repo

The tests used for this report stay in the repository, so every future change (including AI-generated ones) can be re-checked in one command. Results of both runs are committed as JSON.

supabase/migrations/0005_security_fixes.sql
All database fixes in one reviewed migration.
tests/rls_controls.sql
38 database controls: seeds two businesses and an outsider, attacks as each role, rolls back.
tests/edge/edge-controls.ts
9 edge function controls: runs the real functions in Deno against mocked Supabase and Mercado Pago.
tests/code-controls.mjs
6 code controls, including an executed test of the webhook signature verifier.
tests/run-controls.sh
Builds a fresh database, runs everything, writes JSON, exits non-zero on any failure (CI-ready).
results/before.json · results/after.json
The two runs quoted in this report.

Run

$ tests/run-controls.sh . turnos_pro_ci results/latest.json
...
53/53 controls passed  ->  results/latest.json

All 53 controls

IDControlTypeBeforeAfter
C01 A user cannot give himself is_admin = true · F-03 Database FAIL PASS
C02 A user cannot change his own role to admin · F-03 Database FAIL PASS
C03 A user can still edit his own name and phone normal use · F-03 Database PASS PASS
C04 A visitor without login cannot list user emails and phones · F-10 Database FAIL PASS
C05 A logged-in user cannot read other users' profiles · F-10 Database FAIL PASS
C06 A user can read his own profile normal use · F-10 Database PASS PASS
C07 An outsider cannot add himself as owner of another business · F-02 Database FAIL PASS
C08 The owner can add staff to his own business normal use · F-02 Database PASS PASS
C09 An outsider cannot read any business's clients · F-06 Database FAIL PASS
C10 Business B cannot read Business A's clients · F-06 Database FAIL PASS
C11 Business A can read its own clients normal use · F-06 Database PASS PASS
C12 A visitor without login cannot pull a business's agenda · F-05 Database FAIL PASS
C13 Business B cannot pull Business A's agenda · F-05 Database FAIL PASS
C14 Business A can load its own agenda normal use · F-05 Database PASS PASS
C15 A visitor without login cannot cancel an appointment · F-07 Database FAIL PASS
C16 Business B cannot confirm Business A's appointment · F-07 Database FAIL PASS
C17 Status only accepts known values · F-07 Database FAIL PASS
C18 Business A can cancel its own appointment normal use · F-07 Database PASS PASS
C19 An outsider cannot list client ID documents · F-08 Database FAIL PASS
C20 Business B cannot list Business A's documents · F-08 Database FAIL PASS
C21 An outsider cannot upload into Business A's folder · F-08 Database FAIL PASS
C22 Business A can upload to its own folder normal use · F-08 Database PASS PASS
C23 Business A sees only its own documents, not Business B's · F-08 Database FAIL PASS
C24 An outsider cannot host files (e.g. a phishing page) in the public bucket · F-11 Database FAIL PASS
C25 Business B cannot replace Business A's public images · F-11 Database FAIL PASS
C26 Business A can upload its own banner normal use · F-11 Database PASS PASS
C27 Marketing images stay public by design normal use · F-11 Database PASS PASS
C28 client-docs bucket has a size limit and a type allowlist (no HTML/SVG) · F-08 Database FAIL PASS
C29 marketing bucket has a size limit and an image-only allowlist · F-11 Database FAIL PASS
C30 Nobody can write a "paid" payment from the browser (only the verified webhook) · F-04 Database FAIL PASS
C31 A replayed webhook cannot record the same payment twice · F-04 Database FAIL PASS
C32 Server quotes the real price; an invented coupon gives no discount · F-09 Database FAIL PASS
C33 A valid coupon is applied on the server normal use · F-09 Database FAIL PASS
C34 Coupon codes cannot be listed by users · F-09 Database FAIL PASS
C35 Two bookings cannot overlap for the same service · F-12 Database FAIL PASS
C36 Availability hides slots that overlap an existing booking (10:15-10:45) · F-12 Database FAIL PASS
C37 Availability offers no slots in the past · F-12 Database FAIL PASS
C38 Free future slots are still offered normal use · F-12 Database PASS PASS
C39 A forged, unsigned 'payment approved' call changes nothing · F-04 Edge function FAIL PASS
C40 A genuine signed notification (confirmed with Mercado Pago) marks the booking paid normal use · F-04 Edge function FAIL PASS
C41 Webhook errors do not reveal stack traces · F-13 Edge function FAIL PASS
C42 A customer who sends price_cents = 1 is still charged the real price (500000) · F-09 Edge function FAIL PASS
C43 Database error messages are not echoed to the browser · F-13 Edge function FAIL PASS
C44 A foreign website is not granted cross-origin access · F-14 Edge function FAIL PASS
C45 The app's own origin can still call the function normal use · F-14 Edge function PASS PASS
C46 Admin data refused to a non-admin user (server-side check) · F-01 Edge function FAIL PASS
C47 Admin data served to a real admin through the server normal use · F-01 Edge function FAIL PASS
C48 No service_role key in frontend code or VITE_ variables; .env is git-ignored · F-01 Code FAIL PASS
C49 Signature verifier accepts genuine webhooks and rejects forged/replayed ones (executed) · F-04 Code FAIL PASS
C50 Payment status/amount are read from Mercado Pago and checked against the booking price · F-04 Code FAIL PASS
C51 No coupon codes in frontend source · F-09 Code FAIL PASS
C52 Edge functions return generic errors (no stack traces / raw DB messages) · F-13 Code FAIL PASS
C53 Edge functions use an origin allowlist (no '*', never with credentials) · F-14 Code FAIL PASS

Want this for your app?

The Readiness Review is $249 and credited in full toward Graduation. Check in two minutes whether your app qualifies.