Sample report

What the $290 Ship Safe Check delivers.

A redacted example of the report for a fictional app. The format, depth and severity language are the ones you receive; the app, people and values are made up.

SAMPLE. Ledgerly is a fictional app. Hostnames, ids and keys below are invented and redacted to show format only.

Scope and method

One app, three business days.

Ledgerly is invoicing for freelancers: a Next.js frontend on Vercel, Supabase for auth, database and storage, and two edge functions.

Scope

In scope

  • app.ledgerly.example (production) and its Supabase project
  • All 9 tables in the public schema, 2 storage buckets, 2 edge functions
  • Auth settings, Stripe webhook endpoint, response headers
  • The client bundle and source maps as served to browsers

Method

How we tested

  • Written authorization from the owner before any test
  • Three test accounts supplied by the owner: two users and one admin
  • The two-user test on every table, signed in and anonymous, for reads and writes
  • Manual review of policies, functions and settings, read-only dashboard access
  • No load testing, no real customer data touched

Summary

Six findings, five fixed.

Severity follows one scale: Critical, High, Medium, Low. Status is Fixed once we have retested the change, or Open with a date.

SeverityWhat it meansFoundFixed
CriticalAnother user's data can be read or changed with no special access.11
HighA signed-in user can reach data or actions beyond their own.22
MediumMakes another problem easier, or exposes data with limited impact.22
LowHardening that reduces risk; no direct path to data.10
FixedChange made by the owner and retested by us.5 of 6

Findings

Evidence, fix, retest.

SAMPLE · Ledgerly is fictional App: Ledgerly (Next.js, Supabase, Vercel) Engagement: Ship Safe Check Report date: (sample, undated) Reviewer: Unflatline
6Findings
5Fixed
1Open, scheduled
F-01 · DATA

Row Level Security off on the invoices table

Evidence. With only the public anon key and no sign-in, GET /rest/v1/invoices?select=id,client_name,total returned 214 rows across 31 accounts (response kept, ids redacted). Fix. Enabled RLS on invoices; added select, insert, update and delete policies comparing user_id to auth.uid(). Retest. Anonymous request returns []; user A sees only A's 6 invoices.

CriticalFixed
F-02 · ROLES

Users can set their own role to admin

Evidence. Signed in as test user A, PATCH /rest/v1/profiles?id=eq.<A> with {"role":"admin"} returned the row with the new role, and the admin dashboard loaded for A. Fix. Moved roles to a user_roles table with no user write policy; revoked column update on profiles.role. Retest. Same request returns 403; A's role unchanged.

HighFixed
F-03 · FUNCTIONS

The send-invoice edge function trusts user_id from the request body

Evidence. Calling /functions/v1/send-invoice with A's token and B's user_id in the body emailed one of B's invoices to an address A chose. The function uses the service role key, so RLS does not apply inside it. Fix. The function now reads the user from the verified token and ignores user_id in the body. Retest. Request for B's invoice with A's token returns 404.

HighFixed
F-04 · STORAGE

Receipt uploads sit in a public bucket

Evidence. The receipts bucket is marked public. A receipt URL copied from A's account opened in a signed-out private window. File names follow a guessable pattern (date plus invoice number). Fix. Bucket set to private, storage policies limit each user to their own folder, receipts served through 60-second signed URLs. Retest. The old URL returns 400; signed URLs expire as set.

MediumFixed
F-05 · BUILD

Production source maps published next to the bundle

Evidence. /_next/static/chunks/app-3f…a1.js.map answered 200 with the original source, including internal route names and comments. No secrets were in it; the anon key it contains is meant to be public. Fix. productionBrowserSourceMaps turned off in next.config.js; maps now upload privately to the error tracker. Retest. Map URLs return 404.

MediumFixed
F-06 · HEADERS

No Content Security Policy; HSTS max-age is one day

Evidence. The root document sends Strict-Transport-Security: max-age=86400 and no Content-Security-Policy. Fix. Paste-ready vercel.json headers block supplied, with a CSP that allows Supabase and Stripe. The owner is running it in report-only mode first. Retest. Scheduled after the next release.

LowOpen

Limits

What we did not test

  • Denial of service and load: no traffic beyond normal use
  • Social engineering, phishing, or the owner's email and DNS accounts
  • Third-party services themselves (Supabase, Vercel, Stripe), only how Ledgerly uses them
  • The marketing site at ledgerly.example and the mobile app, out of scope
  • Source code beyond what is served to browsers and the edge functions we were given

Readout

A 20-minute call

We walk through each finding, in order of severity, on a 20-minute call with whoever will make the fixes. You get the SQL and config as text you can paste, and the report records the retest status of each fix.

This report describes the app at one point in time. It is not a guarantee that no other issues exist.

Ship Safe Check

Want this for your app?

Book the Ship Safe Check, $290. Includes database rules and auth review. Start with the free check if you'd like to see the browser view first.