Launch Check / Findings report

Demo invoicing app

App
Demo invoicing app (made up for this sample)
Stack
Next.js App Router, Supabase, Stripe in test mode
Tested on
Staging only. Production not touched.
Access
Read-only repo, two test accounts
Findings
9. One critical, three high.
Version
2, updated after the retest

1 / One-page summary

Not ready to charge yet. Four fixes first.

Right now, any person who signs up can read and change every other company's invoices. Fix that and three other problems before you take money. None of the four is a large job.

1Critical
3High
3Medium
1Low
1Info

Fix before you charge anyone

  • Other companies' invoices are visible. Any signed-in user can read and edit all invoices and customers. F-01
  • Anyone can give themselves the Pro plan. The app believes any message that claims to come from Stripe. F-02
  • Invoice PDFs can be downloaded by number. Change the number in the address and you get someone else's invoice. F-03
  • The master database key is in the code history. Anyone who ever gets the repo gets the whole database. F-04
  • Check that production has backups. I could not see production. This takes you ten minutes. F-09

Can wait until after launch

  • Free users can reach Pro features. The buttons are hidden but the features still work. You lose revenue, not data. F-05
  • Attachment links never expire. A forwarded link works for anyone, forever. F-06
  • The sign-in link can send people to another site. Useful to someone running a phishing email. F-07
  • A repeated Stripe message adds a free month. Rare, and it costs you, not the customer. F-08

What is in good shape

Sign-up, password reset and sign-out work as they should. No secret keys are in the code the browser downloads. A normal user cannot reach the admin pages. Cancelling a subscription removes Pro access. Uploads are limited by type and size. You own every account the app depends on.

"Wait" means weeks, not months. I would fix F-05 to F-07 in the first month.

2 / Scope and method

What I tested and how.

How to read a finding

Each finding says how serious it is, where it is, what someone can do with it, how to reproduce it on staging, and the fix I recommend. Every finding in this report was reproduced by hand on staging. I do not report things a tool flagged that I could not confirm.

SeverityWhat it means
CriticalAny user can read or change other customers' data in bulk. Do not launch.
HighData exposure or free paid access that needs a little more effort or access. Fix before charging.
MediumLost revenue, or exposure that needs a specific situation. Fix in the first month.
LowSmall cost or annoyance. Fix when convenient.
InfoNot a defect I could confirm. Something for you to check or decide.

In the steps below, $STAGING_URL is the staging site, $SUPABASE_URL and $ANON_KEY are the staging Supabase project's public URL and anon key, and $USER_B_TOKEN is user B's access token after signing in.

3 / Checklist results

Six areas, every check.

28 checks. 15 passed, 10 failed, 3 not tested. Some findings fail more than one check.

CheckResultFinding
01 Who can see what
Row-level security is on for every table in the public schemaPass
Policies limit rows to the user's own organizationFailF-01
Server routes that bypass row-level security check ownership themselvesFailF-03
A normal member cannot reach admin pages or admin routesPass
A member removed from an organization loses access on the next requestPass
02 Sign-in
Sign-up, email confirmation, password reset and sign-out workPass
Every app page and API route needs a sessionChecked each route in the repo, signed out.Pass
After sign-in, the redirect only goes to the app's own pagesFailF-07
Supabase Auth redirect list has exact URLs, no wildcardsPass
Rate limits on sign-in and password resetSupabase defaults are in place. I did not try to exhaust them on shared staging.Not tested
03 Payments
Checkout prices are set on the server, not sent from the browserPass
The webhook verifies Stripe's signatureFailF-02
The same Stripe event delivered twice has no extra effectFailF-08
Paid features are enforced on the serverFailF-05
Cancelling or a failed renewal removes Pro accessPass
A refund removes accessThe app has no refund handling. Refunds are done by hand in Stripe. Decide if that is enough.Not tested
04 Secrets and keys
No server-only keys in the browser bundleSearched the built JavaScript. Only the anon key and the Stripe publishable key are there, which is correct.Pass
No keys in the current repo filesPass
No keys in git historyFailF-04
The service role key is only used in server codePass
05 Files and documents
Storage buckets are private unless they are meant to be publicFailF-06
Storage policies limit files to the owning organizationFailF-06
PDF and CSV exports check who is askingFailF-03
Uploads are limited by file type and sizePass
06 Deploy and ownership
The previous release can be restoredThe owner rolled staging back one release and forward again while I watched.Pass
Staging has its own database and its own Stripe test keysPass
Database backups exist and a restore has been triedProduction is out of scope, so I could not check it.Not testedF-09
The founder owns the domain, hosting, Supabase, Stripe and repo accountsThe owner showed me the owner screen of each account on a call.Pass

4 / Findings

Nine findings, most serious first.

F-01 / Who can see whatCritical

Any signed-in user can read and change every organization's invoices and customers

Where

Supabase tables public.invoices and public.customers. Row-level security is on. The policies do not look at the organization:

create policy "Authenticated users can read invoices"
on public.invoices for select
to authenticated
using (true);

create policy "Authenticated users can update invoices"
on public.invoices for update
to authenticated
using (true);

customers has the same pair.

What someone can do

Sign up for a free account, then ask the database directly for all rows. They get every organization's invoices and customer contact details. They can also change amounts or mark invoices as paid. The app's own screens filter by organization, so you never see this by clicking around.

Reproduce on staging
  1. Sign in as user B (Org B). Copy the access token from the session.
  2. Run:
    curl "$SUPABASE_URL/rest/v1/invoices?select=id,org_id,total" \
      -H "apikey: $ANON_KEY" \
      -H "Authorization: Bearer $USER_B_TOKEN"
  3. The response includes Org A's invoices.
  4. Send a PATCH to /rest/v1/invoices?id=eq.1042 (an Org A invoice) with the body {"status":"paid"}. It returns 204 and the row changes.
Recommended fix

Replace the policies so each one checks membership. Do the same for customers, and for insert and delete.

drop policy "Authenticated users can read invoices" on public.invoices;
drop policy "Authenticated users can update invoices" on public.invoices;

create policy "Members read their org's invoices"
on public.invoices for select
to authenticated
using (
  org_id in (
    select org_id from public.org_members
    where user_id = (select auth.uid())
  )
);

create policy "Members update their org's invoices"
on public.invoices for update
to authenticated
using (
  org_id in (
    select org_id from public.org_members
    where user_id = (select auth.uid())
  )
)
with check (
  org_id in (
    select org_id from public.org_members
    where user_id = (select auth.uid())
  )
);

The with check part stops a user moving a row into another organization. This relies on org_members letting a user read their own membership rows. It does. I checked that policy and it is correct.

Depends on data state

Yes. It only shows when at least two organizations have data. With one test account everything looks fine, which is why this is easy to miss.

F-02 / PaymentsHigh

The Stripe webhook accepts events that did not come from Stripe

Where

app/api/stripe/webhook/route.ts. The handler reads the body with await req.json() and acts on it. It never checks the stripe-signature header.

What someone can do

Send a made-up "checkout completed" message to the webhook address and get the Pro plan without paying. The address is easy to guess. They only need their own organization id, which the app shows them.

Reproduce on staging
  1. Confirm Org B is on the Free plan.
  2. Run, with Org B's id filled in:
    curl -X POST "$STAGING_URL/api/stripe/webhook" \
      -H "Content-Type: application/json" \
      -d '{"id":"evt_fake_1","type":"checkout.session.completed","data":{"object":{"client_reference_id":"ORG_B_ID"}}}'
  3. The response is 200. Reload the billing page as user B. Org B is on Pro.
Recommended fix

Read the raw body and let the Stripe library verify it before doing anything else.

import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const body = await req.text();
  const signature = req.headers.get('stripe-signature');
  if (!signature) {
    return new Response('Missing signature', { status: 400 });
  }

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch {
    return new Response('Invalid signature', { status: 400 });
  }

  // handle event.type here
  return new Response(null, { status: 200 });
}

Use req.text(), not req.json(). The check needs the exact bytes Stripe sent. STRIPE_WEBHOOK_SECRET is the signing secret of the webhook endpoint in the Stripe dashboard. Staging and production each have their own.

Depends on data state

No. It works against any organization at any time.

F-03 / Files and documentsHigh

The invoice PDF route returns any organization's invoice by number

Where

app/api/invoices/[id]/pdf/route.ts. The route checks that someone is signed in, then loads the invoice with the service role client. That client skips row-level security, and the route does not check the organization itself. Invoice ids are sequential numbers.

What someone can do

Any signed-in user can download any invoice as a PDF by changing the number in the address. They can walk through the numbers and collect all of them.

Reproduce on staging
  1. Sign in as user B in the browser.
  2. Open $STAGING_URL/api/invoices/1042/pdf. Invoice 1042 belongs to Org A.
  3. The PDF downloads.
Recommended fix

Load the invoice with the signed-in user's client, so row-level security decides. Return 404 when nothing comes back.

import { createClient } from '@/lib/supabase/server'; // cookie-based client, not the service role one

export async function GET(
  _req: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  const supabase = await createClient();

  const { data: invoice } = await supabase
    .from('invoices')
    .select('*')
    .eq('id', id)
    .maybeSingle();

  if (!invoice) {
    return new Response('Not found', { status: 404 });
  }

  // build and return the PDF
}

This fix only works after F-01 is fixed. Until then the user's client can read everything too. The CSV export route already uses the user's client, so it becomes safe with F-01.

Depends on data state

Slightly. The number must be an existing invoice. Because the numbers are sequential, any number below the newest one works.

F-04 / Secrets and keysHigh

The Supabase service role key is in git history and was never changed

Where

The file .env.local was committed in the first week of the project and deleted from the repo later. Deleting a file does not remove it from history. The old commits still hold SUPABASE_SERVICE_ROLE_KEY and STRIPE_SECRET_KEY (a test-mode key).

What someone can do

The service role key skips every access rule in the database. Anyone who gets a copy of the repo can read, change or delete all data. That includes a contractor, a tool you connect to the repo, or anyone at all if the repo is ever made public. The repo is private today, which is why this is high and not critical.

Reproduce
  1. In a clone of the repo, run:
    git log --all --oneline -- .env.local
  2. Take the oldest commit id from the list and run:
    git show COMMIT_ID:.env.local
  3. The keys are printed. The owner confirmed that the staging key has not been changed since, and that production was set up the same way.
Recommended fix
  1. Create new keys in the Supabase project settings, for staging and for production. Put the new values in the hosting environment variables and redeploy.
  2. Be aware: if the project uses the older JWT-based keys, changing them also changes the anon key and signs every user out. Do it before launch, not after.
  3. Roll the Stripe test key as well.
  4. Make sure the file cannot come back:
    echo ".env*.local" >> .gitignore
    git rm --cached .env.local --ignore-unmatch

Once the keys are changed, the old ones in history are useless. You do not need to rewrite history.

Depends on data state

No.

F-05 / PaymentsMedium

Pro features are only hidden in the interface, not blocked on the server

Where

app/api/invoices/recurring/route.ts and app/api/invoices/export/route.ts. The buttons for recurring invoices and CSV export are hidden for Free organizations in components/InvoiceToolbar.tsx. Neither route checks the plan.

What someone can do

A Free user who calls the routes directly gets both Pro features. No one else's data is exposed. You lose the upgrade.

Reproduce on staging
  1. Sign in as user B (Free plan). In the browser's developer tools, open the console.
  2. Run:
    await fetch('/api/invoices/recurring', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ customerId: 7, interval: 'month', total: 100 }),
    }).then((r) => r.status);
  3. It returns 201 and a recurring invoice appears for Org B. Customer 7 is one of Org B's own customers.
Recommended fix

Check the plan at the top of each Pro route. Take the organization from the signed-in user's membership, never from the request body.

const { data: org } = await supabase
  .from('organizations')
  .select('plan')
  .eq('id', orgId)
  .single();

if (org?.plan !== 'pro') {
  return Response.json({ error: 'Pro plan required' }, { status: 403 });
}

Put this in one helper and call it from both routes, so the next Pro feature does not forget it.

Depends on data state

Yes. You need an account on the Free plan to see it. An owner testing with their own Pro account never will.

F-06 / Files and documentsMedium

Invoice attachments are in a public bucket and the links never expire

Where

Supabase Storage bucket attachments. The bucket is public. Files are stored at ORG_ID/FILE_ID.pdf, where both ids are random. The app shows the public URL.

What someone can do

The paths cannot be guessed, so a stranger cannot browse the files. But anyone who has a link can open the file without signing in, forever. That covers a forwarded email, a link pasted in a chat, and a team member who has been removed from the organization.

Reproduce on staging
  1. As user A, upload an attachment to an invoice and copy its link. It looks like $SUPABASE_URL/storage/v1/object/public/attachments/ORG_A_ID/FILE_ID.pdf.
  2. Open the link in a private window with no one signed in.
  3. The file downloads.
Recommended fix

Make the bucket private, add a policy, and hand out short-lived links.

update storage.buckets set public = false where id = 'attachments';

create policy "Members read their org's attachments"
on storage.objects for select
to authenticated
using (
  bucket_id = 'attachments'
  and (storage.foldername(name))[1] in (
    select org_id::text from public.org_members
    where user_id = (select auth.uid())
  )
);
const { data, error } = await supabase.storage
  .from('attachments')
  .createSignedUrl(path, 60); // valid for 60 seconds

// send the user to data.signedUrl

Add matching insert and delete policies. The old public links stop working as soon as the bucket is private.

Depends on data state

Yes. Someone must already hold a link.

F-07 / Sign-inMedium

After sign-in, the app will redirect to any address it is given

Where

app/auth/callback/route.ts. The route reads the next value from the address and redirects to it without checking it.

What someone can do

Send your user a link that starts on your real domain. The user signs in on your real page and then lands on the sender's site, which can be a copy of yours asking for a password or card details. No data leaks from your app. It makes phishing your users easier.

Reproduce on staging
  1. Signed out, open $STAGING_URL/login?next=https://example.org.
  2. Sign in as user A with the emailed link.
  3. The browser ends up on example.org.
Recommended fix

Only accept a path inside the app.

const { searchParams, origin } = new URL(request.url);
const next = searchParams.get('next') ?? '/dashboard';

const isLocalPath =
  next.startsWith('/') && !next.startsWith('//') && !next.includes('\\');

return NextResponse.redirect(
  new URL(isLocalPath ? next : '/dashboard', origin)
);

The two extra tests matter. Browsers treat //example.org and /\example.org as another site.

Depends on data state

No. It needs the user to click a link someone sent them.

F-08 / PaymentsLow

The same Stripe event handled twice adds a second month

Where

app/api/stripe/webhook/route.ts. On invoice.paid the handler adds one month to organizations.paid_until. It does not remember which events it has already handled.

What can happen

Stripe sends an event again when your server answers slowly or with an error. That is normal behaviour. Each repeat gives the customer another free month. No attacker is needed and no data is exposed.

Reproduce on staging
  1. Complete a test checkout as user B. Note paid_until for Org B.
  2. Find the invoice.paid event id in the Stripe test dashboard and resend it:
    stripe events resend EVENT_ID
  3. paid_until has moved one more month.
Recommended fix

Record each event id and skip the ones you have seen.

create table public.stripe_events (
  id text primary key,
  received_at timestamptz not null default now()
);

alter table public.stripe_events enable row level security;
// after the signature check, with the service role client
const { error } = await admin.from('stripe_events').insert({ id: event.id });

if (error?.code === '23505') {
  return new Response(null, { status: 200 }); // already handled
}
if (error) {
  return new Response('Database error', { status: 500 });
}

23505 is the Postgres code for a duplicate key. Row-level security with no policies keeps the table closed to everyone except the server. Better still, set paid_until from the period end date Stripe sends, so a repeat writes the same value.

Depends on data state

Yes. It needs Stripe to deliver an event more than once.

F-09 / Deploy and ownershipInfo

No one has tried restoring the database, and I could not confirm production has backups

Where

Supabase project settings, and the supabase/migrations folder. The staging project has no automatic backups on its current plan. Production is out of scope, so I could not look. Rolling the app back to the previous release works. The database is different: migrations only go forward, and no one has restored a backup.

What can happen

A bad migration or a mistaken delete could lose customer invoices with no way back. This is not something an attacker does. It is the ordinary bad day.

What to check
  1. Open the production project in Supabase. Under Database, look at Backups. Confirm that daily backups are listed.
  2. Once, before launch, restore a backup into a new empty project and open the app against it. Until you have done that, you do not know the backups work.
  3. Before any migration that drops or rewrites a column, take a manual copy:
    pg_dump "$DATABASE_URL" --format=custom --file=before-migration.dump
Depends on data state

No.

5 / Retest

After the fixes.

I repeated the steps for every finding on staging after the fixes were deployed. Six are fixed, one is partly fixed and two are open.

No.SeverityFinding and what I saw on retestStatus
F-01CriticalCross-organization read and writeUser B's request now returns only Org B's rows. The update to an Org A invoice changes nothing. Checked select, insert, update and delete on both tables.Fixed
F-02HighUnsigned webhook eventsThe made-up event now gets 400. A real test checkout still upgrades the organization.Fixed
F-03HighInvoice PDF by numberUser B gets 404 for an Org A invoice. User A still gets the PDF.Fixed
F-04HighService role key in git historyThe key from the old commit is now rejected by the staging project. The owner confirmed the same change on production. I did not test production.Fixed
F-05MediumPro features not blocked on the serverRecurring invoices now return 403 for a Free organization. CSV export still works for a Free organization. The plan check was added to one route only.Partly fixed
F-06MediumPublic attachment linksThe bucket is private. The old link returns an error. New links expire after 60 seconds. User B cannot create a link for an Org A file.Fixed
F-07MediumRedirect after sign-inOutside addresses now go to the dashboard. Also tried //example.org and /\example.org.Fixed
F-08LowRepeated Stripe eventsNot changed. The owner chose to do this after launch.Open
F-09InfoBackups and restoreThe owner confirmed that production lists daily backups. No restore has been tried yet.Open

Where that leaves you

The four fixes I asked for before charging are done. Finish F-05 by adding the plan check to the CSV export route. Try one restore before you have data you cannot afford to lose.

6 / Limits

What this review did not cover.