Sample report. PawWalk is a demo app built with deliberately planted problems so we can show you a real report without exposing a real client. Every key in it is fake. The findings, evidence and fix prompts below are exactly what a client receives.
Verdict: Not ready to launch
Reviewed October 6, 2026. Source: demo repository. Reviewer: Liftoff Review.
PawWalk lets dog owners book walks and pay by card. The most serious risk: anyone on the internet can read every customer's home address and key location, using only the public key that ships with the website. Fixing the three Critical findings typically takes about a day with your AI builder; the full list, two to three days.
| Severity | Open findings |
|---|---|
| Critical | 3 |
| High | 5 |
| Medium | 3 |
| Low | 1 |
Vite + React frontend; Supabase (Postgres, Auth, Edge Functions); Stripe Checkout; Resend for email; OpenAI for bios. Entry points reviewed: 4 frontend files that touch data, 3 Edge Functions (one of them a payment webhook), 3 database tables with their policies, and all environment configuration. All nine checklist areas were assessed.
What can happen: Anyone who opens your website can copy the public Supabase key from it and list every booking — home addresses and notes such as gate codes and where the key is hidden — or edit and delete them.
Why: The bookings table was created without row level security, so the public key grants full access to it.
Evidence: supabase/migrations/20260901_init.sql:36
create table public.bookings (
...
access_notes text,
No alter table public.bookings enable row level security exists in any migration (RLS is enabled only for profiles at line 10 and pets at line 29).
Severity rationale: Reachable by anyone with no account; exposes personal and physical-security data of every customer.
Fix prompt — paste into your AI builder:
Create a new Supabase migration (do not edit old ones) that enables row level security on
public.bookingsand adds policies: customers can select and insert only rows wherecustomer_id = auth.uid(); an assigned walker can select rows wherewalker_id = auth.uid(); no one can updateprice_centsorstatusor delete bookings from the client — only server code using the service role may do that. Never useusing (true)orwith check (true)on this table.
How to confirm it's fixed: In the Supabase SQL editor, select relrowsecurity from pg_class where relname = 'bookings'; returns true. Logged out, the app's booking list loads nothing.
What can happen: The secret key is bundled into the website's JavaScript. Anyone can read it and use your Stripe account: issue refunds, read customer and payment data, or create charges.
Why: Checkout sessions are created in the browser with the stripe server library and a hardcoded sk_live_ key.
Evidence: src/lib/payments.ts:4
const stripe = new Stripe('sk_l…');
Severity rationale: Reachable by anyone visiting the site; direct access to money and payment data.
Fix prompt — paste into your AI builder:
Remove the
stripepackage and every secret key from the frontend. Create a Supabase Edge Functioncreate-checkoutthat reads the logged-in user from the request JWT, loads the booking by id and checkscustomer_idequals that user, computes the amount on the server, and creates the Stripe Checkout session withSTRIPE_SECRET_KEYfrom the function's secrets. The frontend calls this function and redirects to the returned URL. No file undersrc/may contain a key starting withsk_.
Also do now, by hand: roll the key in the Stripe dashboard (Developers → API keys). Removing it from the code does not un-leak it.
How to confirm it's fixed: After npm run build, searching the dist folder for sk_live finds nothing, and Stripe shows the old key as rolled.
What can happen: Anyone can send a fake "payment completed" message to your webhook URL and mark any booking as paid.
Why: The webhook trusts whatever JSON it receives; it never checks Stripe's signature.
Evidence: supabase/functions/stripe-webhook/index.ts:6
const event = await req.json();
No call to constructEvent or check of the stripe-signature header exists in the function.
Severity rationale: Reachable by anyone who learns the webhook URL; direct money loss.
Fix prompt — paste into your AI builder:
Update the
stripe-webhookEdge Function to verify Stripe's signature before doing anything: read the raw body as text, read thestripe-signatureheader, and callstripe.webhooks.constructEventAsync(body, signature, STRIPE_WEBHOOK_SECRET). If verification fails, return 400 and change nothing. Mark a booking paid only if the session'samount_totalequals the booking'sprice_cents, and do nothing if it is already paid.
How to confirm it's fixed: A POST to the webhook URL with a made-up body returns 400. A Stripe test-mode payment still marks the booking paid.
What can happen: Anyone with access to the repository — a contractor, a leaked laptop, a repo made public by mistake — gets the service role key, which ignores every security rule in your database.
Why: .env is committed and is not listed in .gitignore.
Evidence: .env:3
SUPABASE_SERVICE_ROLE_KEY=eyJh…
Severity rationale: Needs repository access (not public), but the key's blast radius is the entire database.
Fix prompt — paste into your AI builder:
Remove
.envfrom the repository and add.env*to.gitignore. Move server secrets (SUPABASE_SERVICE_ROLE_KEY,RESEND_API_KEY,OPENAI_API_KEY) to Supabase Edge Function secrets. The frontend may use onlyVITE_SUPABASE_URLandVITE_SUPABASE_ANON_KEY. Never print secret values in code, logs or comments.
Also do now, by hand: rotate the service role, Resend and OpenAI keys in each dashboard. Deleting the file does not remove it from git history.
How to confirm it's fixed: git ls-files | grep .env returns nothing.
What can happen: A customer can change the price in the browser before paying and book a walk for one cent.
Why: The amount is calculated in the browser and passed straight to Stripe and into the booking row.
Evidence: src/pages/NewBooking.tsx:13 and src/lib/payments.ts:14
const price = PRICE_PER_WALK_CENTS * walks;
unit_amount: amountCents,
Severity rationale: Needs only a free account and browser tools; direct revenue loss on every booking.
Fix prompt — paste into your AI builder:
Make the server the only source of prices. In the
create-checkoutEdge Function, accept only awalksinteger, reject values outside 1–20, and compute the amount aswalks × 2500cents. Ignore any amount sent by the client. Removeprice_centsfrom the client-side insert intobookings; set it inside the function using the service role.
How to confirm it's fixed: Editing the request in the browser to send a different amount, or walks = 0, is rejected or charged at the correct price.
What can happen: Anyone can call the bio generator in a loop and run up your OpenAI costs.
Why: The function does not check who is calling and has no limit. Supabase's default JWT check does not help: the public anon key passes it.
Evidence: supabase/functions/generate-bio/index.ts:2
const { name, breed, notes } = await req.json();
Severity rationale: Reachable by anyone; cost is bounded only by your OpenAI account limit, which is not in the repository. It is Critical if no spend limit is set.
Fix prompt — paste into your AI builder:
Protect the
generate-bioEdge Function: reject requests without a logged-in user (verify the JWT and return 401 for anonymous calls), allow at most 10 generations per user per day (count them in abio_generationstable), and reject inputs longer than 500 characters.
Also do now, by hand: set a monthly spend limit and an alert in the OpenAI dashboard.
How to confirm it's fixed: Calling the function while logged out returns 401; the 11th call in a day returns 429.
What can happen: Anyone can send any HTML email to any address from hello@pawwalk.app — phishing that looks like you, and a fast way to get your domain blacklisted and your Resend account suspended.
Why: The function takes the recipient and the full HTML body from the request, with no login check and no limit.
Evidence: supabase/functions/send-invite/index.ts:2 and :14
const { to, message } = await req.json();
html: message,
Severity rationale: Reachable by anyone with the public anon key; damages your sending reputation and brand. Not Critical because it does not expose user data or move money.
Fix prompt — paste into your AI builder:
Change
send-inviteso only logged-in users can call it, each user can send at most 5 invites per day, and the email body comes from a fixed server-side template. The client may send only one recipient address and the inviter's first name — never HTML. Validate the address format.
How to confirm it's fixed: Calling it logged out returns 401; HTML sent in the request does not appear in the email.
What can happen: A customer writes a bio containing a script. When the assigned walker opens the pet profile, the script runs in the walker's browser and can read their login session, which Supabase keeps in the browser's storage.
Why: Bios are rendered as raw HTML, and the AI generator is told it may return HTML.
Evidence: src/pages/PetProfile.tsx:25 and supabase/functions/generate-bio/index.ts:13
<div dangerouslySetInnerHTML={{ __html: pet.bio }} />
Severity rationale: Needs a free account; lets one user take over another user's account.
Fix prompt — paste into your AI builder:
Stop rendering pet bios as HTML. In
PetProfile.tsxreplacedangerouslySetInnerHTMLwith plain text rendering (<p>{pet.bio}</p>). Ingenerate-bio, change the system prompt to return plain text only, and strip any HTML tags from the model output on the server before returning it.
How to confirm it's fixed: Saving <img src=x onerror=alert(1)> as a bio shows the text itself, with no popup.
What can happen: Any logged-in user can change their own role to admin. Nothing in the app enforces this role yet, so there is no direct damage today — but the moment an admin feature relies on it, anyone can become an admin.
Why: The update policy on profiles allows any change, including to role.
Evidence: supabase/migrations/20260901_init.sql:16
create policy "Users can update profiles"
on public.profiles for update
using (true)
Severity rationale: Latent today (role is not enforced anywhere); becomes High as soon as it is.
Fix prompt — paste into your AI builder:
Replace the
profilesupdate policy with one that allows a user to update only their own row (auth.uid() = id), and add a trigger that rejects any change to therolecolumn unless the request uses the service role. Add a database functionis_admin()that returns whether the current user's role isadmin, for use in admin policies.
How to confirm it's fixed: Logged in as a normal user, supabase.from('profiles').update({ role: 'admin' }) on your own row returns an error.
What can happen: Anyone can open the admin page by typing localStorage.isAdmin = 'true' in the browser console. Today that adds little beyond DA-1, because the bookings data is already open; after DA-1 is fixed, admin actions need a real server-side check.
Why: Admin access is decided by a flag in the browser's storage.
Evidence: src/pages/Admin.tsx:6
const isAdmin = localStorage.getItem('isAdmin') === 'true';
Severity rationale: On its own it hides UI rather than protecting data; the data exposure is counted under DA-1.
Fix prompt — paste into your AI builder:
Remove the
localStorageadmin flag. The admin page should ask the server whether the user is an admin (call theis_admin()database function), and the refund action should go through an Edge Function that checksis_admin()before changing any booking.
How to confirm it's fixed: As a normal user, setting localStorage.isAdmin = 'true' still shows "Not authorized" and refund calls fail.
What can happen: Session tokens appear in the console, where screen-recording tools, browser extensions and support screenshots can capture them.
Why: A debug log was left in the auth provider.
Evidence: src/context/AuthProvider.tsx:12
console.log('session loaded', data.session);
Severity rationale: Requires access to the user's browser or recordings; limited reach.
Fix prompt — paste into your AI builder:
Remove
console.log('session loaded', data.session)fromAuthProvider.tsxand any other logging of session, user or token objects anywhere in the app. Add the ESLint ruleno-consoleas an error for production builds.
How to confirm it's fixed: After logging in, the browser console shows no session object.
What can happen: When payments or bookings fail for real users, you find out from complaints, not alerts.
Why: No error-monitoring tool is installed in the frontend or the Edge Functions.
Evidence: package.json:10 — the dependencies list no monitoring library (for example Sentry).
Severity rationale: Operational gap; no direct security impact.
Fix prompt — paste into your AI builder:
Add error monitoring (Sentry or similar) to the React frontend and to every Supabase Edge Function, with an email alert for new errors. Do not send personal data such as addresses or session tokens to the monitoring tool.
How to confirm it's fixed: A deliberately thrown test error produces an alert within minutes.
pets has row level security with owner-scoped policies (20260901_init.sql:29–34).src/lib/supabase.ts:4–5).npm audit --omit=dev.Fix the Critical and High findings, then reply with the new commit. We re-run the affected checks once within 14 days at no extra cost.
This review is a static analysis of the code provided, verified finding by finding. It is not a penetration test and cannot prove the absence of vulnerabilities.
$750, fixed, in 72 hours. Re-check included. No actionable findings, full refund.
Book a review