Liftoff ReviewBook a review

Launch Readiness Review: PawWalk

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.

Summary

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

Fix first

  1. DA-1 — Anyone can read and change every booking, including addresses and key locations
  2. SK-1 — Your live Stripe secret key is downloaded by every visitor
  3. PY-1 — Anyone can mark any booking as paid without paying
  4. SK-2 — A key that bypasses all database security is stored in the repository
  5. PY-2 — Customers can set their own price
  6. AB-1 — Anyone can run your AI bio writer on your OpenAI bill
  7. AB-2 — Anyone can send emails from your domain through the invite function
  8. IN-3 — A pet bio can run code in a walker's browser and steal their session

What we looked at

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.

Findings

CRITICALDA-1Anyone can read and change every booking

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.bookings and adds policies: customers can select and insert only rows where customer_id = auth.uid(); an assigned walker can select rows where walker_id = auth.uid(); no one can update price_cents or status or delete bookings from the client — only server code using the service role may do that. Never use using (true) or with 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.

CRITICALSK-1Your live Stripe secret key is downloaded by every visitor

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 stripe package and every secret key from the frontend. Create a Supabase Edge Function create-checkout that reads the logged-in user from the request JWT, loads the booking by id and checks customer_id equals that user, computes the amount on the server, and creates the Stripe Checkout session with STRIPE_SECRET_KEY from the function's secrets. The frontend calls this function and redirects to the returned URL. No file under src/ may contain a key starting with sk_.

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.

CRITICALPY-1Anyone can mark any booking as paid without paying

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-webhook Edge Function to verify Stripe's signature before doing anything: read the raw body as text, read the stripe-signature header, and call stripe.webhooks.constructEventAsync(body, signature, STRIPE_WEBHOOK_SECRET). If verification fails, return 400 and change nothing. Mark a booking paid only if the session's amount_total equals the booking's price_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.

HIGHSK-2A key that bypasses all database security is stored in the repository

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 .env from 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 only VITE_SUPABASE_URL and VITE_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.

HIGHPY-2Customers can set their own price

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-checkout Edge Function, accept only a walks integer, reject values outside 1–20, and compute the amount as walks × 2500 cents. Ignore any amount sent by the client. Remove price_cents from the client-side insert into bookings; 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.

HIGHAB-1Anyone can run your AI bio writer on your OpenAI bill

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-bio Edge 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 a bio_generations table), 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.

HIGHAB-2Anyone can send emails from your domain through the invite function

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-invite so 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.

HIGHIN-3A pet bio can run code in a walker's browser and steal their session

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.tsx replace dangerouslySetInnerHTML with plain text rendering (<p>{pet.bio}</p>). In generate-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.

MEDIUMDA-3Any user can give themselves the admin role

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 profiles update 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 the role column unless the request uses the service role. Add a database function is_admin() that returns whether the current user's role is admin, 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.

MEDIUMDA-5The admin page is protected only in the browser

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 localStorage admin flag. The admin page should ask the server whether the user is an admin (call the is_admin() database function), and the refund action should go through an Edge Function that checks is_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.

MEDIUMPV-1Login sessions are printed to the browser console

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) from AuthProvider.tsx and any other logging of session, user or token objects anywhere in the app. Add the ESLint rule no-console as an error for production builds.

How to confirm it's fixed: After logging in, the browser console shows no session object.

LOWOP-2Errors in production will go unnoticed

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.

Passed checks worth knowing

What we did not check

Re-check

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.

Want this for your app?

$750, fixed, in 72 hours. Re-check included. No actionable findings, full refund.

Book a review