Production Risk Audit — InvoicePilot
| Application | InvoicePilot — invoicing for freelancers and small studios |
| Platform | Vercel (static SPA + serverless functions) · Supabase (Postgres + Auth) · Stripe · Resend |
| Stack | Vite 5 + React 18 + TypeScript, React Router 6 |
| Stage | Demonstration build. No real users; the repository states its credentials are placeholders |
| Access provided | Read-only access to the working tree at demo-app/, including src/, api/, supabase/migrations/, package.json, package-lock.json, .env, the deploy configuration, the built output in dist/ (bundle, stylesheet and source map), and the git history of the repository that contains it |
| Method | Reading code, configuration and build output only. Nothing was executed: no dev server, no build, no test run, no package script. No network request was made to the application, its platform, a package registry or a vulnerability database. No credential found was used |
| Produced by | An automated pass over the code (production-risk-audit), reviewed against the repository and signed by a person before delivery |
| Date | 9 August 2026 |
| Prepared by | Vibe2Prod |
⚠️ THIS IS NOT A CLIENT ENGAGEMENT
InvoicePilot is a deliberately-vulnerable demonstration application, built in-house for training and demonstration purposes. Its defects were planted on purpose so that they can be found, explained and fixed in a teaching setting.
This report is a specimen of our audit format, produced against that specimen application. It describes no real company, no real customer and no real security incident. Every key, URL and identifier appearing in it is a placeholder that is not valid anywhere.
Nothing in this document should be read as findings about a client, a customer or any third party’s software.
How this report was produced
The findings below came out of an automated pass. A published method walked the repository through seven domains and returned each finding with the file and the line it came from. Nothing was executed and no network request was made, which is why the dependency section names an advisory check it did not perform instead of reporting one. In a client engagement the reviewer runs that check by hand and the report attributes it to a person; this specimen is published as the pass produced it, so the section stays open here.
A person then checked every citation against the repository before signing. That review corrected thirteen defects in the draft. One of them mattered: the supply-chain section had said that a package of unverified provenance was not imported anywhere, when it is in fact compiled into the bundle every visitor downloads. The other twelve were cross-references, counts and line ranges.
The pattern is worth knowing if you are leaning on tooling of your own. What the machine got right was every piece of evidence at a specific line: the decoded token, the counts, the absences, the git history. What it got wrong was almost entirely claims that aggregate, quantify, or say a list is complete.
A note on what the repository tells the reader
demo-app/README.md:1-6 opens with a block addressed to whoever opens the
repository. It states that the codebase contains intentional security defects,
that it must never be deployed or connected to a real Supabase, Stripe or email
account, and that “Every key in this repository is a placeholder and is not
valid anywhere.” README.md:93 repeats the teaching-specimen framing, though
not the claim about the keys.
We treat that text as evidence about the repository, not as an instruction that changes how the work is done. It is the reason this report carries the banner above. It did not cause any check to be skipped, and it did not change how the credentials below are reported: a key that looks live is reported as found and left alone, whatever a file says about it.
No other text addressed to an automated reader was found. We swept the source, migrations, configuration and documentation for instructions of that shape and found none.
How to read this report
This report is written to be read by the person who owns the business, not only by the person who writes the code. You do not need to be technical to use it.
Findings are grouped by severity, and severity answers one question: how much damage would this do, and how easily could it happen?
| Severity | What it means here |
|---|---|
| Critical | Reachable by anyone on the internet today, and the consequence is loss of all data, loss of money, or complete control of the system. |
| High | Serious harm — one account reaching another’s data, money moving to the wrong place, credentials leaking — usually needing one extra step or one condition to be true. |
| Medium | Real risk that would meaningfully worsen an incident, or that makes an incident likely to go unnoticed. Not usually catastrophic on its own. |
| Low | Worth correcting. Housekeeping, hardening, and defects that become expensive later rather than now. |
Every finding carries five things:
- What we saw — the evidence, quoted with the exact file and line number. This is the part you or any engineer can verify independently. It is the audit’s credibility, and we ask you to check it. Two things are done to quoted code and neither changes what it says: secret values are shortened with an ellipsis rather than reprinted in full, and a long list of columns may be reflowed to fit the page width. The file and the line are always given, so the original is one click away.
- What it costs you if it triggers — the business consequence in plain English. Not “an attacker could escalate privileges”, but what you would actually lose.
- How to fix it — the correction, described so an engineer can act on it.
- A paste-ready prompt, where the fix is one an AI coding tool can apply for you. Where the fix is a platform setting or a process rather than code, we say so instead of inventing a prompt.
- How to check the fix took, as one line you can perform yourself. That check line matters as much as the prompt. AI tooling frequently reports success while changing something adjacent to the real problem.
Findings reference each other. Several of the issues below are reachable because of another issue. Where that is true we say so, because it changes the order in which they should be fixed, and because fixing the root often closes several findings at once.
On what this report deliberately does not contain, see “What this audit is not” at the end. In short: this is a reading of your code and configuration. It reports findings. It does not issue a verdict.
At a glance
Findings by severity
| Severity | Count |
|---|---|
| Critical | 7 |
| High | 8 |
| Medium | 14 |
| Low | 4 |
| Total | 33 |
We report severity counts only. This report contains no readiness score and no launch recommendation, by design — see “What this audit is not”.
The five recurring flaws
We check for the same five defects in every audit and report which of them appeared. In this application, all five are present.
| # | Flaw | Found? | Where |
|---|---|---|---|
| 1 | Hardcoded keys | ✅ Found | Supabase service-role key compiled into the browser bundle (IP-01); Stripe and Resend secrets written into a database table by a migration (IP-03); the populated secrets file protected only by an ignore rule outside the project (IP-19) |
| 2 | RLS disabled | ✅ Found | Row Level Security explicitly switched off on all six tables, with blanket grants to the public role (IP-02); a public view that bypasses it regardless (IP-04) |
| 3 | Missing input validation | ✅ Found | Invoice notes rendered as raw HTML (IP-09); stored payment URL never validated (IP-12); amounts and tax rates parsed without checks (IP-23); the webhook and the email endpoint trust their entire request body (IP-06, IP-07) |
| 4 | Broken auth logic | ✅ Found | Account identity read from browser storage (IP-05); invoices read and written by id with no owner check (IP-08); webhook accepts unsigned requests (IP-06); email endpoint requires no authentication (IP-07); admin data fetched before the admin check (IP-10); admin rights granted by email suffix (IP-11); any profile column writable from the browser (IP-14); deletes run with the master key and never check ownership (IP-15) |
| 5 | Console / data leakage | ✅ Found | Plaintext password written to the browser console (IP-13); full original source published via production source maps (IP-16); stack traces returned to API callers and printed in the interface (IP-17) |
The shape of the problem
Three structural decisions produce most of the findings below, and they compound.
- The database has no access control. Row Level Security is off on every table and the anonymous role holds full grants. Postgres is doing exactly what it was told: letting anyone read and write everything.
- The master key is published. The Supabase service-role key, the credential that bypasses every database rule, is compiled into the JavaScript every visitor downloads. We confirmed this in the built file, not only in the source.
- There is no server-side enforcement anywhere. Every rule this application has about who may do what is written in the browser, and a rule written in the browser is a suggestion. The two serverless functions that do run on a server hold the master key and check nothing.
Individually each is serious. Together they mean the application’s security currently depends on nobody looking.
One further pattern is worth naming before the findings. The schema was built in three migrations, and each one widened access rather than narrowing it: the first granted everything in the schema to the public roles, the second granted a new table and a new view to them as well, and the third added a settings table holding secrets, granted that too, and switched row security off across the board to make a symptom go away. Nothing reviewed any of it, because there is nothing in this repository that reviews anything (IP-28).
Critical findings
IP-01 · Critical · The database master key is compiled into the app every visitor downloads
What we saw
Supabase issues two keys. The anon key is meant to be public. The service-role key is the master key: it bypasses every access rule in the database, and it belongs only on a server.
This application reads the service-role key into the browser application:
demo-app/src/lib/supabase.ts:5—const supabaseServiceKey = import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY;demo-app/src/lib/supabase.ts:15-20— a second, fully privileged client is built from it and exported assupabaseAdmin
In Vite, any variable prefixed VITE_ is substituted into the JavaScript
bundle at build time. The prefix is the mechanism that publishes it. The
variable carries that prefix in demo-app/.env:3, it is declared in the
browser’s own environment typing at demo-app/src/vite-env.d.ts:6, and
demo-app/README.md:60 records it as being for “app + functions”. The
arrangement is deliberate rather than accidental.
The same variable name is read on the server side as well —
demo-app/api/send-invoice.ts:6 and demo-app/api/stripe-webhook.ts:5 both
call process.env.VITE_SUPABASE_SERVICE_ROLE_KEY — so there is exactly one
service-role variable, shared between the code that runs on your server and the
code you hand to visitors.
We confirmed this in the built output, not only in the source. The key is
present in demo-app/dist/assets/index-DMjTepJ_.js:95, the file served to every
browser.
It does not appear there as readable text. Searching that bundle for
service_role returns zero matches, which is why a plain-text scan of the
build would miss it entirely. The value is a JSON Web Token and its role claim
is base64-encoded inside the payload segment. We extracted the two JWT-shaped
strings in the bundle and decoded their payloads locally:
{"iss":"supabase","ref":"qkzlfphrmxdwvbnaocie","role":"anon","iat":1719792000,"exp":2035368000}
{"iss":"supabase","ref":"qkzlfphrmxdwvbnaocie","role":"service_role","iat":1719792000,"exp":2035368000}
The second is the master key. Its exp claim is 2035368000, so the token does
not expire until 2034.
The key is then used from browser code in four places, all in
demo-app/src/lib/api.ts: line 67 (delete any invoice), line 102 (delete any
client), line 146 (list every account) and line 156 (grant or revoke admin
rights).
What it costs you if it triggers
Everything. Anyone who opens your site, presses F12 and copies that key gets unrestricted read and write access to your entire database: every customer, every invoice, every payment record, every email address and VAT number, for every account on the platform. They can read it all, silently change amounts, or delete the lot. No login required, no exploit required, no skill required.
Because the key is already in the bundle, you cannot know whether it has been taken. It must be treated as compromised.
Rotating this key is not optional and not deferrable. It is the single action that closes the largest hole in the system.
How to fix it
- Rotate the service-role key in the Supabase dashboard first. The published one is burned. Do this before the code changes, because the code changes are worthless while the old key is still valid.
- Delete the
supabaseAdminclient from browser code entirely (src/lib/supabase.ts:15-20). There is no safe way to hold this key in a browser. It cannot be obfuscated, minified or hidden into safety. - Rename the variable so it can never be published again. Remove the
VITE_prefix. Call itSUPABASE_SERVICE_ROLE_KEY, set it only as a server-side environment variable in Vercel, and update the twoapi/handlers that currently read theVITE_-prefixed name. - Move the four privileged operations to server-side endpoints that check who is calling and what they are entitled to do (see IP-10 and IP-15).
Paste-ready prompt
In this Vite + React + Supabase project, the Supabase service-role key is being
exposed to the browser. Fix it as follows, and do not preserve any browser-side
path to the service role.
1. In src/lib/supabase.ts, delete the `supabaseServiceKey` constant and the
entire exported `supabaseAdmin` client. Keep only the anon-key client.
2. Rename the environment variable from VITE_SUPABASE_SERVICE_ROLE_KEY to
SUPABASE_SERVICE_ROLE_KEY everywhere it appears, including .env,
.env.example, README.md, src/vite-env.d.ts, api/send-invoice.ts and
api/stripe-webhook.ts. The VITE_ prefix must not appear on any secret.
3. Find every import of `supabaseAdmin` in src/ and remove it. The affected
functions in src/lib/api.ts are deleteInvoice, deleteClient, listAllProfiles
and setAdminFlag. Replace each with a fetch call to a new serverless endpoint
under api/ that performs the operation server-side.
4. In the api/ functions, read the key from process.env.SUPABASE_SERVICE_ROLE_KEY.
Do not add any fallback that reads a service-role key from import.meta.env.
After the AI applies it, check: rebuild, then extract every JWT-shaped string
from dist/assets/*.js, base64-decode each token’s middle segment, and confirm
no token in dist/ contains "role":"service_role". Searching the bundle
for the plain text service_role is not a sufficient check — it passes today,
while the key is still there, because the role name is base64-encoded inside the
token.
IP-02 · Critical · Row Level Security is switched off on every table, and the public role holds full permissions
What we saw
Row Level Security (RLS) is the Postgres feature that makes one customer’s rows invisible to another. It is the entire basis of tenant separation in a Supabase application. A migration turns it off on all six tables:
demo-app/supabase/migrations/20241004091200_settings_and_dashboard_fix.sql:19-24
alter table public.profiles disable row level security;
alter table public.clients disable row level security;
alter table public.invoices disable row level security;
alter table public.invoice_items disable row level security;
alter table public.payments disable row level security;
alter table public.app_settings disable row level security;
The comment immediately above it, at lines 17-18, records why:
-- Queries returned 0 rows for every logged-in user once row security was on,
-- turning it back off until the policies are figured out.
RLS was enabled, no policies were written, so every query correctly returned nothing, and the blocking symptom was resolved by removing the protection rather than by writing the policies. The temporary fix is still in place.
Separately, the schema grants the public roles full permissions on everything:
demo-app/supabase/migrations/20240902101500_init_schema.sql:63-66—grant all on all tables in schema public to anon, authenticated;plus the same for all sequences and execute on all functionsdemo-app/supabase/migrations/20240917143000_payments_and_links.sql:43-44—grant all on public.payments to anon, authenticated;and the same on its sequencedemo-app/supabase/migrations/20241004091200_settings_and_dashboard_fix.sql:43—grant all on public.app_settings to anon, authenticated;
anon is the role attached to the public API key, which is to say anyone at
all. grant all includes SELECT, INSERT, UPDATE and DELETE.
Supabase exposes every table in the public schema over an HTTPS API
automatically. With RLS off and grant all to anon, that API is an open door
to the whole database, reachable with nothing but the public key from dist/
and a browser.
This finding rests on the migration files describing the live database. Row
security and grants can also be changed from the Supabase dashboard, which we
could not read (see What we could not see). If somebody re-enabled row security
there after this migration ran, the live state differs from what is recorded
here. That is worth confirming directly, and it cuts both ways: a dashboard change
that is not in a migration will be silently reverted by the next db push.
What it costs you if it triggers
Any person on the internet can read every invoice, client record and profile you hold — names, email addresses, billing addresses, VAT numbers, amounts and payment history — and can equally modify or delete them.
For a business handling invoices, three specific consequences follow. Your customer list, your revenue and your margins are readable by competitors. Invoice amounts and payment destinations can be altered by outsiders (see IP-12). And because this is personal data of EU clients, an exposure of it is a personal-data breach under GDPR carrying a 72-hour notification duty to the supervisory authority, with the reputational cost of telling your customers that their details were readable by anyone.
There is no attack to detect here. Ordinary, well-formed API requests return the data. Nothing in your logs would look unusual.
How to fix it
This is the root cause of a large share of the findings below, and it should be
fixed as a unit with the grants. Enabling RLS while anon still holds
grant all leaves several paths open, and revoking grants without RLS leaves
authenticated users able to read each other’s rows.
- Revoke the blanket grants; grant only what each role needs.
- Enable RLS on all six tables.
- Write policies keyed on
auth.uid(), the identity Postgres verifies from the request’s signed token, which the user cannot forge. This is the correct replacement for the browser-supplied identity in IP-05. - Re-test the application. If screens go empty, a policy is missing — write it. An empty screen is the protection working.
Paste-ready prompt
Write a new Supabase migration for this project that restores database access
control. Do not edit the existing migration files; add a new one.
The migration must:
1. Revoke the over-broad grants:
revoke all on all tables in schema public from anon, authenticated;
revoke all on all sequences in schema public from anon, authenticated;
Then grant only: select, insert, update, delete on public.profiles,
public.clients, public.invoices, public.invoice_items to authenticated; and
usage on the relevant sequences to authenticated. Grant anon nothing on these
tables.
2. Enable row level security on all six tables: profiles, clients, invoices,
invoice_items, payments, app_settings.
3. Create policies scoped by auth.uid():
- profiles: a user may select and update only the row where id = auth.uid().
Do NOT allow the user to update the is_admin or plan columns; restrict the
update with a column-level grant so those two are not user-writable.
- clients: full access only where user_id = auth.uid().
- invoices: full access only where user_id = auth.uid().
- invoice_items: access only where the parent invoice's user_id = auth.uid().
- payments: no access for anon or authenticated at all; this table is written
only by the server using the service role, which bypasses RLS.
- app_settings: no access for anon or authenticated at all.
4. Set the WITH CHECK clause on every insert and update policy so a user cannot
create a row under, or move a row to, another user's user_id.
Use auth.uid() as the identity source. Never trust a user_id sent by the client.
After the AI applies it, check: apply the migration to a staging project,
create two test accounts and give each an invoice. Signed in as the first,
attempt to read the second account’s invoice by its id — it must return zero
rows, not an error and not data. Then, using only the public anon key and no
login, call /rest/v1/invoices and /rest/v1/profiles directly; both must
return zero rows or a permission error. Finally confirm the app’s own screens
still populate for a signed-in user. If a screen is empty, a policy is missing
rather than the fix being wrong.
IP-03 · Critical · Payment and email secrets are stored in a database table the public can read, and are sent to the browser
What we saw
A migration inserts third-party secrets as ordinary rows in an ordinary table:
demo-app/supabase/migrations/20241004091200_settings_and_dashboard_fix.sql:9-14
insert into public.app_settings (key, value)
values
('stripe_secret_key', 'sk_live_51PdEXAMPLE...'),
('stripe_webhook_secret', 'whsec_EXAMPLE...'),
('resend_api_key', 're_EXAMPLE_...'),
('invoice_footer', 'Thank you for your business.')
Note the sk_live_ prefix at line 11: that is the Stripe live-mode secret
key format, the one that moves real money, not a test key.
That table has RLS disabled (line 24 of the same file) and is granted to the
public role (line 43, grant all on public.app_settings to anon, authenticated;).
Supabase publishes it over the REST API automatically, so a single
unauthenticated request to /rest/v1/app_settings?select=* returns all four
rows.
The application then compounds this by fetching the secret into the browser.
demo-app/src/lib/api.ts:140-143:
export async function getSetting(key: string): Promise<string | null> {
const { data } = await supabase.from('app_settings').select('value').eq('key', key).maybeSingle();
return data?.value ?? null;
}
and demo-app/src/pages/Settings.tsx:25:
getSetting('stripe_secret_key').then(setStripeKey).catch(console.error);
The interface displays only the first twelve characters
(demo-app/src/pages/Settings.tsx:120,
value={stripeKey ? `${stripeKey.slice(0, 12)}…` : 'not connected'}), but the
truncation is cosmetic and it happens after the full value has been sent. The
complete key is in the HTTP response, in the page’s JavaScript memory and in the
browser’s network tab, for every user who opens the settings screen.
The three secrets in this table are the same three values held in
demo-app/.env:6, :7 and :9, so the database copy is a second publication of
credentials that already exist in one place.
What it costs you if it triggers
The Stripe live secret key is the credential that controls your money. Whoever holds it can create charges against your customers’ saved payment methods, issue refunds to accounts they control, read your entire transaction and payout history, and change where your payouts are sent. Losing it is indistinguishable from losing control of your business bank account, and fraudulent charges made with your own key are your liability toward your customers.
The Resend API key lets someone send email as your domain, compounding IP-07. The webhook secret defeats the verification that IP-06 asks you to add, so this finding must be fixed alongside IP-06 or the fix there is hollow.
How to fix it
Secrets belong in the platform’s environment-variable store, held server-side, never in an application database table and never in a browser.
- Rotate all three secrets now — the Stripe secret key, the Stripe webhook secret and the Resend key. Rotate before changing code.
- Delete the three secret rows from
app_settings, and enable RLS on the table with no policy foranonorauthenticated. - Store the rotated values as Vercel environment variables without the
VITE_prefix, read only insideapi/functions. - Delete
getSettingand the whole “Stripe connection” card from the settings screen. There is no legitimate reason for a browser to receive a secret key, even a truncated one.
Paste-ready prompt
This project stores third-party API secrets in a Postgres table called
app_settings and fetches them into the browser. Remove that pattern entirely.
1. Write a new migration that deletes the rows with keys 'stripe_secret_key',
'stripe_webhook_secret' and 'resend_api_key' from public.app_settings,
enables row level security on the table, and creates no policy for anon or
authenticated, so only the service role can reach it. Keep the
'invoice_footer' row, which is not a secret.
2. Delete the getSetting function from src/lib/api.ts.
3. In src/pages/Settings.tsx, delete the stripeKey state, the useEffect that
calls getSetting, and the entire "Stripe connection" card that renders it.
4. Make the api/ functions read STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET and
RESEND_API_KEY from process.env only.
No secret may be readable by the anon or authenticated database role, and no
secret may be sent to the browser, not even truncated for display.
After the AI applies it, check: call /rest/v1/app_settings?select=* with only
the public anon key — it must return an empty array or a permission error, not
rows. Open the settings screen with the browser network tab recording and confirm
no response body contains a value beginning sk_, whsec_ or re_. Confirm in
the Stripe and Resend dashboards that the old keys are revoked. Deleting the rows
does nothing if the keys themselves still work.
IP-04 · Critical · Every invoice, with client and issuer contact details, is readable by anyone counting upwards
What we saw
A database view exists to power the public payment page, and it is granted to the anonymous role:
demo-app/supabase/migrations/20240917143000_payments_and_links.sql:22-40
create or replace view public.public_invoices as
select
i.id, i.number, i.status, i.currency, i.amount_cents, i.tax_rate,
i.due_date, i.notes, i.payment_link,
c.name as client_name,
c.email as client_email,
p.company_name as issuer_company,
p.email as issuer_email,
p.vat_number as issuer_vat
from public.invoices i
left join public.clients c on c.id = i.client_id
left join public.profiles p on p.id = i.user_id;
followed at line 42 by grant select on public.public_invoices to anon, authenticated;
Three things make this more serious than a payment page needs to be.
The view carries far more than a payment page requires. To pay an invoice a
client needs the amount, the number, the due date and who it is from. This view
additionally exposes the client’s email address, the issuer’s email address and
the issuer’s VAT number, none of which the payment flow uses for anything but
decoration (demo-app/src/pages/PayInvoice.tsx:66 and :89-90).
There is no unguessable identifier. The view is queried by primary key
(demo-app/src/lib/api.ts:106-115), and invoices use bigserial, which is a
sequential integer starting at 1
(demo-app/supabase/migrations/20240902101500_init_schema.sql:27). The public
route is /pay/:invoiceId (demo-app/src/App.tsx:19) and the link the app hands
you is built from the raw id (demo-app/src/pages/InvoiceDetail.tsx:89,
const payLink = `${window.location.origin}/pay/${invoice.id}`;). So /pay/1,
/pay/2, /pay/3 walks the entire invoice book of every account on the
platform.
The view is not security_invoker. A Postgres view created this way runs
with its owner’s privileges by default, so it would keep returning every row
even after RLS is enabled on the underlying tables in IP-02. Fixing IP-02 alone
does not close this finding.
What it costs you if it triggers
Your entire order book is public and enumerable by a loop that takes minutes to write. Anyone can harvest who all your customers are, what each was charged, when, whether they have paid, plus a clean list of email addresses for both your clients and every business using your platform.
The commercial damage is that a competitor can price against your actual rates and approach your named customers. The legal damage is unauthorised disclosure of personal data — client names, emails and VAT numbers — across every tenant, again engaging GDPR breach-notification duties. The follow-on damage is that a harvested list of invoice ids is exactly the input needed to exploit IP-06 at scale, and a harvested list of client email addresses is the input for a convincing invoice-fraud campaign using IP-07.
How to fix it
Public links must be unguessable, and must expose only what payment requires.
- Add an unguessable token column to
invoices— a UUID or a long random string — and make the public route/pay/:token, not/pay/:id. - Recreate the view to select by that token and drop
client_email,issuer_emailandissuer_vatfrom it. - Declare the view
with (security_invoker = on)so it respects the RLS policies of the underlying tables rather than bypassing them. - Consider expiring the token once the invoice is paid.
Paste-ready prompt
This Supabase project exposes a public view `public_invoices` that is queried by
sequential integer id, letting anyone enumerate every invoice. Fix it.
1. Write a migration that adds a `public_token uuid not null default
gen_random_uuid()` column to public.invoices, with a unique index, and
backfills existing rows.
2. Recreate the public_invoices view WITH (security_invoker = on), selecting by
public_token instead of id. Remove the client_email, issuer_email and
issuer_vat columns from the view. Expose public_token and keep the numeric id
out of the view entirely.
3. Grant select on the view to anon only.
4. Update getPublicInvoice in src/lib/api.ts to look up by public_token.
5. Change the route in src/App.tsx from /pay/:invoiceId to /pay/:token and update
src/pages/PayInvoice.tsx accordingly.
6. Update src/pages/InvoiceDetail.tsx line 89, which builds the shareable link,
to use the invoice's public_token.
7. Remove the rendering of issuer_email, issuer_vat and client_email from
src/pages/PayInvoice.tsx, and remove those fields from the PublicInvoice type
in src/lib/types.ts.
After the AI applies it, check: open a valid payment link and confirm the page
still renders the amount, number and due date. Then change one character of the
token in the URL — it must return not-found, not another invoice. Try /pay/1
and /pay/2 and confirm neither resolves. Finally query
/rest/v1/public_invoices?select=* with the anon key and confirm the response
contains no email address and no VAT number.
IP-05 · Critical · Which account you are is read from browser storage, where the user can edit it
What we saw
The application determines the identity of the current user by reading a value
out of localStorage:
demo-app/src/lib/api.ts:4-6
export function currentUserId(): string {
return localStorage.getItem('invoicepilot.uid') || '';
}
That value is written at demo-app/src/lib/auth.tsx:53
(localStorage.setItem('invoicepilot.uid', currentUser.id)).
localStorage is ordinary browser storage. The person using the browser can
change it at will from the developer console. It is not a security boundary in
any sense.
This browser-supplied value is then used as the authority for who owns what:
demo-app/src/lib/api.ts:13—listInvoicesfilters.eq('user_id', owner)demo-app/src/lib/api.ts:76—listClientsfilters the same waydemo-app/src/lib/api.ts:37—createInvoicestampsuser_id: currentUserId()demo-app/src/lib/api.ts:87—createClientstamps the samedemo-app/src/lib/api.ts:132—updateProfiletargets.eq('id', currentUserId())demo-app/src/lib/api.ts:169—sendInvoiceEmailsends it to the server as the claimed issuer (see IP-07)
Because RLS is off (IP-02), the database performs no independent check. The filter in the query is the only access control, and the user supplies the filter.
Changing one line in the browser console —
localStorage.setItem('invoicepilot.uid', '<another user id>') — and reloading
causes the application to load and display that other account’s invoices and
clients as though they were yours. The other user’s id is not secret:
listAllProfiles returns it (IP-10), and the profiles table is openly readable
(IP-02).
The same substitution makes updateProfile write to the other account’s profile
row. See IP-14 for what that permits.
What it costs you if it triggers
Complete failure of separation between customers, in both directions. A user can read any other user’s invoices and client list, and can write to another user’s profile. The steps require no tools beyond the browser that is already open, and take about fifteen seconds.
For a business, this is the failure that ends customer trust permanently. “Another customer could see my client list and my revenue” is not a bug report you recover from quietly. It is also, again, a reportable personal-data breach.
How to fix it
Stop deriving identity from the client. The user’s identity must come from the signed session token that Supabase verifies server-side, which the user cannot alter.
- Enable RLS with
auth.uid()-based policies (IP-02). This makes the database enforce ownership regardless of what the browser sends, and is the real fix. - Delete
currentUserId()and every use of it. - Where the user’s id is genuinely needed in the browser for display, read it
from the authenticated session object, never from
localStorage. - Stop writing the profile and user id into
localStorageatsrc/lib/auth.tsx:52-53. It serves no purpose the session does not already serve, and it widens the damage from IP-09.
Paste-ready prompt
In this React + Supabase app, user identity is read from localStorage, which the
user can edit. Remove that pattern completely.
1. Delete the currentUserId() function from src/lib/api.ts.
2. In src/lib/auth.tsx, remove the two localStorage.setItem calls that store
'invoicepilot.profile' and 'invoicepilot.uid', and the corresponding
removeItem call.
3. In src/lib/api.ts, remove the explicit user_id filters from listInvoices and
listClients. With row level security enabled the database returns only the
caller's rows; an explicit filter is redundant and gives a false impression of
enforcement.
4. In createInvoice and createClient, stop setting user_id from the client.
Instead set the column's DEFAULT to auth.uid() in the database, or read the id
from the authenticated session via supabase.auth.getUser().
5. In updateProfile, remove the .eq('id', currentUserId()) targeting and rely on
the row level security policy to scope the update to the caller's own row.
Identity must come only from the verified Supabase session, never from
localStorage or any client-supplied value.
After the AI applies it, check: with two test accounts, sign in as the first,
set localStorage.setItem('invoicepilot.uid', '<second account id>') in the
console and reload. You must still see only the first account’s data. Then
confirm a search for invoicepilot.uid across src/ returns nothing. This check
only proves anything once IP-02 is fixed — verify them together.
IP-06 · Critical · The Stripe webhook accepts unsigned requests, so anyone can mark any invoice paid
What we saw
demo-app/api/stripe-webhook.ts is the endpoint Stripe calls to report a
completed payment. It parses whatever arrives and acts on it:
demo-app/api/stripe-webhook.ts:19
const event = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
At lines 30-47, if event.type is checkout.session.completed or
payment_intent.succeeded, it inserts a payment record and updates the invoice:
await supabase
.from('invoices')
.update({ status: 'paid', paid_at: new Date().toISOString() })
.eq('id', invoiceId);
Nowhere in the file is the request verified as genuinely coming from Stripe.
Stripe signs every webhook with a shared secret and sends the signature in the
stripe-signature header; the receiver is required to verify it. We searched the
entire src/ and api/ trees for any signature handling —
STRIPE_WEBHOOK_SECRET, stripe-signature, constructEvent,
stripe_webhook_secret — and found no occurrences at all. The secret is
defined in demo-app/.env:7 and stored in the database at
demo-app/supabase/migrations/20241004091200_settings_and_dashboard_fix.sql:12,
but no code ever reads it. The stripe package is not a dependency of this
project.
The endpoint is public by necessity, and CORS is fully open on it
(demo-app/vercel.json:9-11, and again in code at
demo-app/api/stripe-webhook.ts:9).
The invoice id is taken from the caller-supplied body at line 24
(object.metadata?.invoice_id ?? object.client_reference_id), and the amount
recorded at line 35 comes from the same body. Neither is checked against the
invoice being settled (see also IP-21).
Anyone who can send an HTTP request can post a small JSON document to
/api/stripe-webhook naming any invoice id, and that invoice will be marked
paid.
What it costs you if it triggers
You deliver the work and never get the money, and your own system tells you that you were paid.
An invoice flipped to paid disappears from your outstanding list, stops being
chased, and is recorded as collected revenue on the dashboard
(demo-app/src/pages/Dashboard.tsx:43,46). The fabricated payment also lands in
the payments table and is displayed as a genuine payment on the invoice
(demo-app/src/pages/InvoiceDetail.tsx:183-193).
The loss is silent and open-ended. It is bounded only by how many invoice ids someone tries, and invoice ids are sequential integers anyone can enumerate (IP-04). Because your books now disagree with your bank, the discrepancy typically surfaces at reconciliation — weeks later, after the work is delivered and the customer relationship is spent.
How to fix it
Verify the Stripe signature before trusting a single field of the payload, and reconcile the amount before marking anything paid.
- Read the raw request body. Signature verification operates on the exact bytes Stripe sent, so the body must not be parsed first. On Vercel this means disabling the automatic body parser for that route.
- Verify with the official Stripe library’s
constructEvent, usingSTRIPE_WEBHOOK_SECRETfromprocess.env. - Reject anything that fails verification with a 400 and process nothing.
- Confirm the paid amount matches the invoice total before setting
paid. - Note that the project does not currently depend on the
stripepackage; it will need to be added.
Paste-ready prompt
In api/stripe-webhook.ts (a Vercel serverless function), the Stripe webhook is
processed without verifying that the request actually came from Stripe. Fix it.
1. Add the official `stripe` package as a dependency.
2. Disable Vercel's automatic body parsing for this route and read the raw
request body as a Buffer, because signature verification must run on the exact
bytes Stripe sent.
3. Read the `stripe-signature` request header and verify the payload with
stripe.webhooks.constructEvent(rawBody, signature,
process.env.STRIPE_WEBHOOK_SECRET).
4. If verification throws, respond 400 and process nothing further. Do not log
the raw body on failure.
5. Only after successful verification, use the parsed event.
6. Before marking the invoice paid, load the invoice and confirm the received
amount equals the invoice total including tax. If it does not match, record
the payment but leave the invoice unpaid and flag it for review.
7. Remove the Access-Control-Allow-Origin header from this function. A Stripe
webhook is a server-to-server call and needs no CORS header at all.
Read STRIPE_WEBHOOK_SECRET from process.env only. Never read it from the
database.
After the AI applies it, check: send a handcrafted POST to the endpoint with a
valid-looking body and no stripe-signature header — it must return 400 and the
invoice must remain unpaid. Then use the Stripe CLI to forward a genuinely signed
checkout.session.completed event and confirm it is still accepted and
processed. Both halves matter: a fix that rejects everything passes the first
check and breaks your payments.
IP-07 · Critical · The invoice-email endpoint requires no authentication, and sends to any address given
What we saw
demo-app/api/send-invoice.ts sends invoice emails through Resend from your
domain. The handler performs no authentication whatsoever. There is no token
check, no session check, no shared secret and no origin restriction:
demo-app/api/send-invoice.ts:27-38
export default async function handler(req: any, res: any) {
res.setHeader('Access-Control-Allow-Origin', '*');
...
const { invoiceId, userId, to } = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
All three values come from the request body. userId — whose business the email
claims to be from — is asserted by the caller and used to look up the
issuer at lines 47-51. The recipient is likewise caller-controlled:
demo-app/api/send-invoice.ts:53
const recipient = to || invoice?.clients?.email;
There is no validation that to is a well-formed address, that it belongs to the
invoice’s client, or that the caller is entitled to send anything.
The function then sends from your verified domain (line 59,
from: `${issuer?.company_name || 'InvoicePilot'} <invoices@invoicepilot.app>` ),
with the display name also taken from caller-influenced data.
The message body is assembled at lines 15-24 by string concatenation, and inserts
invoice.notes directly into the HTML at line 21 (<div>${invoice.notes || ''}</div>)
with no escaping. Invoice notes are writable by anyone while IP-02 stands.
CORS is wide open on the route both here (line 28) and in
demo-app/vercel.json:9-11, so the endpoint can be called from any website in a
visitor’s browser as well as directly.
The function additionally updates the invoice at lines 72-75 using the
service-role client, so an unauthenticated caller can also flip invoices to
sent and inflate reminder_count.
What it costs you if it triggers
You are running an open email relay on your own verified sending domain.
Anyone can send mail that genuinely originates from invoicepilot.app, passes
SPF and DKIM, and looks entirely legitimate, to any address they choose, with
content they influence. The natural abuse is invoice fraud: send your real
customer a real-looking invoice from your real domain with a payment link
pointing at the attacker’s account (IP-12 supplies the link). Your customer pays
the wrong party, and every technical signal tells them the message was genuine.
The second cost is your domain reputation. A burst of spam from your domain gets
invoicepilot.app blocklisted, and once the deliverability of your domain is
gone, your genuine invoices stop arriving in customers’ inboxes. That is an
outage of your revenue collection that takes weeks to unwind with mailbox
providers. The third cost is the Resend bill and account suspension for abuse.
How to fix it
The endpoint must establish who is calling and confirm they own the invoice before sending anything.
- Require the caller’s Supabase access token in an
Authorizationheader, verify it server-side, and derive the user id from the verified token, never from the request body. - Load the invoice and confirm its
user_idmatches the verified caller. Reject with 403 otherwise. - Remove the caller-supplied
toparameter entirely. Send to the invoice’s client’s stored email address and nowhere else. - HTML-escape every interpolated value in the email template,
notesespecially. - Replace the wildcard CORS header with your own origin.
- Add rate limiting per user.
Paste-ready prompt
api/send-invoice.ts is an unauthenticated Vercel serverless function that sends
email from our verified domain to any address supplied in the request body.
Secure it.
1. Require an Authorization: Bearer <token> header. Verify the token with
supabase.auth.getUser(token) using the service-role client. If verification
fails, return 401 and send nothing.
2. Delete `userId` from the request body contract. Use only the user id from the
verified token.
3. Load the invoice by invoiceId and confirm invoice.user_id equals the verified
user id. If not, return 403 and send nothing.
4. Delete the `to` field from the request body contract entirely. Send only to
invoice.clients.email. If that address is missing, return 400.
5. Add an escapeHtml helper and apply it to every value interpolated into the
email template in invoiceHtml(), including invoice.number, clients.name,
issuer.company_name, invoice.currency and especially invoice.notes.
6. Replace Access-Control-Allow-Origin '*' with the application's own origin,
read from an environment variable.
7. Update the caller in src/lib/api.ts sendInvoiceEmail to attach the current
session's access token in the Authorization header and to stop sending userId
and to.
Also remove the error response that returns err.stack to the caller; log
server-side and return a generic message.
After the AI applies it, check: POST to /api/send-invoice with no
Authorization header — it must return 401 and no mail must be sent. Signed in
as one account, request sending of an invoice belonging to another account — it
must return 403. Confirm that supplying a to field is ignored and the mail
still reaches the invoice’s own client. Finally put <img src=x onerror=alert(1)>
in an invoice’s notes, send it, and view the received email source: the tags must
appear as escaped text, not as markup.
High findings
IP-08 · High · Invoices are read, changed and settled by id, with no check that they are yours
What we saw
Four functions in the data layer filter on the record’s id and nothing else, and none of them consults the owner.
demo-app/src/lib/api.ts:20-29 — getInvoice:
const { data, error } = await supabase
.from('invoices')
.select('*, clients(*)')
.eq('id', id)
.single();
demo-app/src/lib/api.ts:54-64 — updateInvoice:
const { data, error } = await supabase
.from('invoices')
.update(patch)
.eq('id', id)
.select('*, clients(*)')
.single();
demo-app/src/lib/api.ts:117-126 — listPayments filters only
.eq('invoice_id', invoiceId).
getInvoice is called with the raw URL parameter at
demo-app/src/pages/InvoiceDetail.tsx:28, and the route is
/invoices/:invoiceId (demo-app/src/App.tsx:30). The only barrier in front of
it is RequireAuth (demo-app/src/components/RequireAuth.tsx:13), which checks
that a user is signed in and nothing else. So any signed-in user who types
another account’s invoice number into the address bar is served that invoice,
together with the joined client record — name, email, company and billing
address — and then its payment history.
updateInvoice takes a Partial<Invoice> and passes it to the database
unfiltered. Partial<Invoice> covers every column declared at
demo-app/src/lib/types.ts:26-43, including user_id, status, paid_at,
amount_cents, tax_rate and payment_link. TypeScript types are erased at
build time and constrain nobody at runtime. The application already uses this to
settle an invoice from the browser:
demo-app/src/pages/InvoiceDetail.tsx:53-56 sets
{ status: next, paid_at: ... } behind the “Mark as paid” button at line 103.
Two consequences follow, and they survive different fixes. While RLS is off
(IP-02) this is reachable by anyone at all, signed in or not, straight over the
REST API. After IP-02 is fixed it is still a defect, because an owner can
still set amount_cents, status, paid_at and payment_link on their own
invoices with no server ever seeing the change — which means the Stripe webhook
in IP-06 was never the only way an invoice becomes paid, and reconciling your
books against Stripe will not find the difference.
What it costs you if it triggers
Any customer of yours can read any other customer’s invoice, and with it that customer’s client contact details, by changing a number in the address bar. That is the same disclosure as IP-04 but from inside the product, where it is not even necessary to know the public payment route exists.
The write path is the more expensive half. A record that says “paid” in your system, with a timestamp, that no payment processor ever confirmed, is the exact condition that makes accounts unauditable. You cannot distinguish a real settlement from a fabricated one after the fact, because both look identical in the invoices table.
How to fix it
- Fix IP-02 first. Ownership belongs in the database, enforced on every read and write regardless of which client sends it.
- Add the owner predicate to
getInvoice,updateInvoiceandlistPaymentsas well, so the application does not depend on a single layer. - Narrow
updateInvoiceto the columns a user may legitimately change:notes,due_date,client_id,currency,tax_rate,amount_centson a draft. Neveruser_id, and neverstatus/paid_atfrom the browser. - Make settlement a server operation.
status: 'paid'andpaid_atshould be set only by the verified webhook handler (IP-06), or by an explicit server-side “record a manual payment” endpoint that writes a payment row at the same time, so every paid invoice has something behind it.
Paste-ready prompt
In src/lib/api.ts, getInvoice, updateInvoice and listPayments filter only by the
record's id, and updateInvoice accepts an unfiltered patch object. Fix all three.
1. Add an owner predicate to getInvoice and listPayments. getInvoice must filter
.eq('user_id', <verified session user id>) in addition to the id; listPayments
must first confirm the invoice belongs to the caller and return an empty array
otherwise.
2. Change updateInvoice to accept only an object with the optional keys notes,
due_date, client_id, currency, tax_rate and amount_cents. Build the update
payload explicitly from those keys and ignore anything else in the argument.
Do not spread the caller's object into the update.
3. Remove status and paid_at from the browser write path entirely. Delete the
changeStatus('paid') call behind the "Mark as paid" button in
src/pages/InvoiceDetail.tsx and replace it with a fetch to a new
api/record-manual-payment.ts endpoint that verifies the caller's token,
confirms ownership, inserts a payments row and sets the invoice paid in the
same transaction.
4. Keep the existing draft/sent/void transitions client-side only if they are
also re-checked server-side; otherwise move them too.
The row level security policy is the real control. These changes stop the
application from asking the database to do the wrong thing in the first place.
After the AI applies it, check: with two accounts, sign in as the first and
open /invoices/<an id belonging to the second account> — the page must show
not-found, not the invoice. From the console, call updateInvoice with
{ status: 'paid' } and with { user_id: '<another id>' } and confirm both are
ignored. Confirm that a paid invoice always has a matching row in the payments
table.
IP-09 · High · Invoice notes are rendered as raw HTML on the public payment page
What we saw
Invoice notes are passed through a formatting helper and injected into the page as unescaped HTML.
demo-app/src/lib/format.ts:34-41
export function formatNotes(notes: string | null | undefined): string {
if (!notes) return '';
return notes
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" target="_blank">$1</a>')
.replace(/\n/g, '<br />');
}
The function adds markup but never removes any. The input is not escaped at any point, so any HTML already in the notes passes through intact.
The result is rendered with React’s explicit escape hatch in two places:
demo-app/src/pages/PayInvoice.tsx:70—<div className="notes" dangerouslySetInnerHTML={{ __html: formatNotes(invoice.notes) }} />demo-app/src/pages/InvoiceDetail.tsx:163— the same call
React escapes content by default; dangerouslySetInnerHTML switches that
protection off. A note containing <img src=x onerror="..."> executes.
Two things raise the severity. First, the vulnerable page is the public,
unauthenticated payment page, the one you send to your customers, so the victim
is your client rather than yourself. Second, while IP-02 stands, notes are
writable by anyone: an outsider can update any invoice’s notes over the REST API
and wait for the client to open the link. Signed-in users can also set notes
through the normal form (demo-app/src/pages/InvoiceDetail.tsx:42).
The stolen material is immediately valuable because the session is kept in
localStorage (demo-app/src/lib/supabase.ts:9, persistSession: true), along
with the profile and user id (demo-app/src/lib/auth.tsx:52-53). localStorage
is readable by any script running on the page, so this is a direct path to
account takeover for a signed-in viewer of InvoiceDetail.
The same unescaped notes are also injected into the outgoing email
(demo-app/api/send-invoice.ts:21), covered under IP-07.
What it costs you if it triggers
Script chosen by an outsider runs inside your application, on a page you asked your customer to open, under your domain name.
Against a signed-in user, it reads the session token from localStorage and
hands over the account: invoices, clients, settings. Against your client on the
payment page, it rewrites what they see — the amount, the payment details, or
the destination of the pay button — on a page that is genuinely yours and shows a
valid certificate. The most likely monetisation is silently redirecting the
payment, which the client has no way to detect.
Because the injected content is stored in your database rather than in a link, it is served to everyone who views that invoice, and it stays until someone finds it.
How to fix it
Escape first, then add formatting. Never the other way round.
- Escape
&,<,>,"and'in the raw note before applying any replacement. - Validate that URLs matched by the link rule begin with
http://orhttps://before building an anchor, and addrel="noopener noreferrer". - Better still, drop
dangerouslySetInnerHTMLand render notes as plain text, or use a maintained sanitiser if formatting is required.
Paste-ready prompt
In src/lib/format.ts, formatNotes() builds HTML from user-supplied invoice notes
without escaping, and the result is rendered with dangerouslySetInnerHTML in
src/pages/PayInvoice.tsx and src/pages/InvoiceDetail.tsx. This is a stored XSS
vulnerability. Fix it.
1. In formatNotes, first escape the input: replace & with &, < with <,
> with >, " with " and ' with '. Do this before any other
replacement runs.
2. Only then apply the bold, italic, link and newline transformations.
3. For the link transformation, verify the matched URL starts with http:// or
https:// before emitting an anchor tag, and add rel="noopener noreferrer"
alongside target="_blank".
4. Add a unit test that passes '<img src=x onerror=alert(1)>' through formatNotes
and asserts the output contains no '<img' and does contain '<img'.
Do not remove the escaping to make any existing formatting work. Escaping runs
first, unconditionally.
After the AI applies it, check: save an invoice whose notes are
<img src=x onerror=alert(1)>**bold** and open both the invoice detail page and
the public payment page. No alert may fire, the literal tag text must be visible
on screen, and **bold** must still render as bold, which proves escaping was
added without breaking the formatting feature.
IP-10 · High · The admin screen loads every account before it checks whether you are an admin
What we saw
demo-app/src/pages/AdminPanel.tsx fetches the full list of accounts in an
effect that runs on mount, with no condition attached:
demo-app/src/pages/AdminPanel.tsx:14-22
useEffect(() => {
listAllProfiles()
.then((rows) => {
console.log('[InvoicePilot] admin: loaded accounts', rows);
setProfiles(rows);
})
.catch(setError)
.finally(() => setLoading(false));
}, []);
The access check exists, but it is further down the file and governs only what is drawn on screen:
demo-app/src/pages/AdminPanel.tsx:35-42
if (!isAdmin) {
return (
<div className="empty">
<h1>Admin</h1>
<p>You don’t have access to this area.</p>
</div>
);
}
In React, the effect runs regardless of what the component returns. So any
signed-in user who navigates to /admin triggers the fetch, receives every
account record, and has it written to their browser console (line 17), while
being shown a polite refusal message. The data arrives; only the table is
withheld.
The fetch itself uses the service-role client:
demo-app/src/lib/api.ts:145-153, where listAllProfiles calls
supabaseAdmin.from('profiles').select('*'). The same applies to setAdminFlag
at lines 155-161, which writes is_admin through supabaseAdmin. Both bypass
any database rule by design, and both run in the browser (see IP-01).
select('*') returns every column of every profile: id, email, full name,
company name, VAT number, plan and admin status, for every user on the platform
(demo-app/src/lib/types.ts:1-10).
What it costs you if it triggers
Your complete customer list — every user’s email address, real name, company and
VAT number — is handed to any signed-in user who types /admin in the address
bar. That is a one-line action requiring no tooling and no knowledge, and it
produces exactly the dataset a competitor would want and exactly the dataset that
makes a GDPR notification unavoidable.
The privilege-granting function is worse than the disclosure. Because
setAdminFlag runs with the master key from the browser, anyone who has read the
bundle can grant themselves administrative status permanently, and that survives
the cosmetic checks being fixed later.
How to fix it
Administrative data must be fetched by a server that verifies the caller’s rights before it reads anything.
- Move
listAllProfilesandsetAdminFlagintoapi/endpoints that verify the caller’s token and confirm theis_adminflag on their server-loaded profile before doing any work. - Gate the fetch on the client too, so it never fires for non-admins, but treat that purely as tidiness rather than protection.
- Select only the columns the screen displays rather than
*. - Remove the console logging of the account list (see IP-13).
Paste-ready prompt
In src/pages/AdminPanel.tsx, the useEffect that calls listAllProfiles() runs
before and independently of the `if (!isAdmin)` check, so non-admin users still
receive every account record. The underlying calls also use the browser-side
service-role client. Fix both.
1. Create api/admin-list-profiles.ts and api/admin-set-role.ts as Vercel
serverless functions. Each must require an Authorization: Bearer token, verify
it with supabase.auth.getUser(), load the caller's own profile row server-side
using the service-role client, and return 403 unless that row has
is_admin = true. Only then perform the operation.
2. api/admin-list-profiles.ts must select only id, email, full_name,
company_name, plan, is_admin and created_at, not '*'.
3. Rewrite listAllProfiles and setAdminFlag in src/lib/api.ts to call these
endpoints with fetch, attaching the session access token. Remove all use of
supabaseAdmin from src/.
4. In AdminPanel.tsx, guard the useEffect so it does not run unless isAdmin is
true, and move the isAdmin early-return above the effect.
5. Delete the console.log of the loaded account rows.
The server-side is_admin check is the real control. The client-side guard is
cosmetic and must not be the only check.
After the AI applies it, check: sign in as an ordinary non-admin user, open the
browser network tab, navigate to /admin, and confirm that either no request for
profiles is made or it returns 403 — the response body must not contain other
users’ email addresses. Confirm the console prints no account data. Then sign in
as a genuine admin and confirm the screen still works.
IP-11 · High · Administrator rights are granted by email address suffix
What we saw
demo-app/src/lib/auth.tsx:21
const ADMIN_EMAIL_DOMAIN = '@invoicepilot.app';
demo-app/src/lib/auth.tsx:107-110
const isAdmin = useMemo(() => {
if (profile?.is_admin) return true;
return (user?.email ?? '').endsWith(ADMIN_EMAIL_DOMAIN);
}, [profile, user]);
There are two ways to become an administrator. The first, profile.is_admin, is
a real database flag. The second is having an email address that ends in
@invoicepilot.app.
Sign-up is open and self-service (demo-app/src/pages/Signup.tsx:22 calls the
auth wrapper, which reaches supabase.auth.signUp at
demo-app/src/lib/auth.tsx:89), and the application places no restriction on which
address may register. Whether registering anything@invoicepilot.app yields an
immediately usable session depends on whether email confirmation is enabled in
the Supabase project — a dashboard setting we could not inspect (see What we
could not see). With confirmation disabled, the session is issued at once and
the account is an administrator by suffix alone.
Independently of that, the check is decorative in both branches. It runs in the
browser, and it controls only whether a link is rendered
(demo-app/src/components/AppLayout.tsx:26) and whether a screen draws. The
privileged operations behind it do not consult it at all; they use the
service-role key directly (IP-10).
The is_admin column itself is writable from the browser, which closes the loop:
see IP-14.
What it costs you if it triggers
Administrative access to your platform is available to anyone who can obtain an address at your own domain, and administrative access here means the full customer list and the ability to grant further admin rights.
The deeper cost is architectural. A string comparison on an email address is being used where an authorisation decision belongs. Any future employee who leaves, any address that gets recycled, any alias or catch-all on the domain becomes an administrator automatically and silently. There is no record of who holds the privilege, because the privilege is not stored anywhere. It is inferred.
How to fix it
- Delete the email-suffix branch entirely. Admin status must come only from the
is_admincolumn. - Make
is_adminnon-writable by users, enforced by the RLS policy in IP-02 rather than by the client. - Enforce the check server-side wherever it matters (IP-10). Keep the client check only to decide whether to draw a link.
- Switch email confirmation on in the Supabase Auth settings.
Paste-ready prompt
In src/lib/auth.tsx, administrator status falls back to checking whether the
user's email address ends with a hardcoded domain. Remove that.
1. Delete the ADMIN_EMAIL_DOMAIN constant.
2. Change the isAdmin useMemo to return Boolean(profile?.is_admin) only, with no
email-based fallback.
3. Add a comment stating that this value controls UI visibility only, and that
every privileged operation must re-check is_admin server-side.
Do not replace the email check with any other client-side heuristic.
After the AI applies it, check: register an account with an address at the
application’s own domain and confirm the Admin link does not appear and /admin
refuses. Confirm a search for invoicepilot.app across src/ returns no
authorisation logic. Separately, open the Supabase dashboard under Authentication
and confirm email confirmation is required — the code fix does not change that
setting.
IP-12 · High · The pay button sends your customer wherever the stored link points, unchecked
What we saw
The public payment page renders the destination straight from the database:
demo-app/src/pages/PayInvoice.tsx:78-84
<a
className="btn primary"
style={{ display: 'inline-block', marginTop: 22 }}
href={invoice.payment_link || '#'}
>
Pay {formatMoney(total, invoice.currency)}
</a>
payment_link is a free-text column
(demo-app/supabase/migrations/20240902101500_init_schema.sql:38,
payment_link text) with no constraint, and it is exposed through the public
view (demo-app/supabase/migrations/20240917143000_payments_and_links.sql:32).
Nothing validates its scheme, its host, or that it points at Stripe at all.
While IP-02 stands, that column is writable by anyone over the REST API without
authentication, and it is also writable through updateInvoice (IP-08). So an
outsider can point the pay button of any invoice at a destination they control,
and the page will render it under your domain, labelled with the correct amount.
We also note that nothing in this codebase ever writes payment_link. We
searched src/, api/ and supabase/ for every reference: the column is
declared in the schema, selected by the view, declared in
demo-app/src/lib/types.ts:38,65, and read at PayInvoice.tsx:81. It is never
assigned. Likewise STRIPE_SECRET_KEY is never read by any code, and
STRIPE_PUBLISHABLE_KEY is exported at demo-app/src/lib/supabase.ts:22 and
imported nowhere — it is absent from the built bundle for that reason. The Stripe
Checkout integration described at demo-app/README.md:31 does not exist in this
codebase. The pay button therefore currently resolves to '#' and does nothing,
while the webhook that marks invoices paid (IP-06) is fully wired and publicly
callable.
What it costs you if it triggers
Your customer clicks “Pay €12,000” on your genuine payment page and the money goes to someone else’s account. The page is served from your domain over your certificate and shows the correct invoice details, so there is no signal your customer could reasonably be expected to notice.
You then have an angry customer who has demonstrably paid, an invoice that is unpaid, and a dispute about who absorbs the loss. Combined with IP-07 the whole sequence can be driven from outside: send the real-looking invoice email from your domain, and have the link inside it point wherever you like.
The unfinished payment integration compounds the risk differently. An invoice can be marked paid by an unsigned webhook call, or by a browser write (IP-08), while no code path ever legitimately collects money. Any “paid” status in this system is currently unbacked by a real payment.
How to fix it
- Generate payment links server-side from the Stripe API and store the returned URL. Never accept one from a client.
- Validate on render: parse the URL and refuse to display the button unless the
scheme is
https:and the host is an expected Stripe host. Render a disabled state otherwise. - Close the write path by fixing IP-02 and IP-08 so only the owning server can set the column.
- Resolve the gap between the documented Stripe Checkout flow and the absent implementation before any of this is relied upon.
Paste-ready prompt
In src/pages/PayInvoice.tsx, the pay button's href comes straight from the
database column invoice.payment_link with no validation, which allows payment
redirection. Add strict validation.
1. Create a helper `isTrustedPaymentUrl(url: string): boolean` in
src/lib/format.ts that returns true only if the value parses as a URL, its
protocol is exactly 'https:', and its hostname is in an allowlist of expected
Stripe hosts (for example checkout.stripe.com, buy.stripe.com,
pay.stripe.com). Return false for anything else, including relative URLs,
javascript:, data: and any other scheme.
2. In PayInvoice.tsx, render the pay button only when
isTrustedPaymentUrl(invoice.payment_link) is true. Otherwise render a
disabled, non-clickable element reading "Payment link unavailable — please
contact the sender."
3. Add unit tests covering: a valid https Stripe URL passes; a http:// URL fails;
'javascript:alert(1)' fails; a lookalike host such as
'checkout.stripe.com.evil.example' fails; null and empty string fail.
Use the URL constructor for parsing. Do not validate with a substring or regular
expression check on the hostname, because that is bypassable by lookalike
domains.
After the AI applies it, check: set an invoice’s payment_link to
https://evil.example/pay and open the public page — the button must be
disabled, not rendered as a link. Repeat with javascript:alert(1) and confirm
nothing executes. Set it to a genuine Stripe checkout URL and confirm the button
works normally. Confirm the lookalike-host test passes, since that is the check a
substring match fails.
IP-13 · High · The password is written to the browser console, alongside sessions and customer data
What we saw
On a failed sign-in, the submitted password is logged in clear text:
demo-app/src/pages/Login.tsx:31
console.error('[InvoicePilot] login failed', { email, password, err });
This is not the only leak. Debug logging left across the application publishes
session tokens and personal data. We counted seventeen console calls across
src/ and api/; the material ones are:
| Location | What is written |
|---|---|
src/pages/Login.tsx:31 |
email and password in clear text |
src/lib/auth.tsx:58 |
the full restored session object, including access and refresh tokens |
src/lib/auth.tsx:68 |
every auth state change, with the new session’s user |
src/lib/auth.tsx:50 |
the full profile record |
src/lib/auth.tsx:46 |
the Postgrest error and the signed-in user object, when the profile fails to load |
src/lib/auth.tsx:85,95 |
the user object on sign-in and sign-up |
src/pages/Dashboard.tsx:34 |
the user plus every invoice and every client |
src/pages/AdminPanel.tsx:17 |
every account on the platform |
src/pages/InvoiceDetail.tsx:30 |
the full invoice |
src/pages/PayInvoice.tsx:17 |
the public invoice record |
api/send-invoice.ts:54 |
recipient address and full issuer profile, to server logs |
api/stripe-webhook.ts:20 |
JSON.stringify(event) — the entire Stripe event, to server logs |
The two server-side entries go to Vercel’s log store rather than the browser,
which changes who sees them but not the fact of retention. The Stripe event at
api/stripe-webhook.ts:20 carries the payer’s name, email and address, and the
same object is additionally written to the database as raw_payload (IP-21).
The session persisted to localStorage (src/lib/supabase.ts:9) and the profile
written there (src/lib/auth.tsx:52-53) are readable by any script on the page,
which is what makes IP-09 an account-takeover path rather than a defacement.
What it costs you if it triggers
Passwords are reused. A password captured here is likely the user’s password elsewhere, which turns a defect in your application into a compromise of your customer’s email or bank. If a user is screen-sharing, on a support call, or on a shared machine with the console open, the password is there on screen. Any browser extension with page access can read all of it.
Logging passwords also converts a routine incident into a serious one on disclosure. If these logs are captured anywhere — an error-reporting tool, a session-replay recorder, a support bundle — you are storing plaintext credentials, which is indefensible under GDPR’s security obligations and in any customer security review.
The customer data written to server logs spreads personal data into a system with different retention and access rules from your database, usually without that being considered or documented.
How to fix it
- Remove the password from the log line at
Login.tsx:31immediately. This is a one-line change and should not wait for anything else. - Remove the session, user, profile, invoice, client and account logging.
- Replace them with a real error-reporting tool that scrubs credentials, and log identifiers rather than records — an invoice id, not the invoice.
- In
api/stripe-webhook.ts:20, logevent.idandevent.typeonly. - Strip
consolecalls from production builds as a backstop, but do not treat that as a substitute for removing them.
Paste-ready prompt
This codebase logs credentials and personal data to the console. Remove all of
it.
1. In src/pages/Login.tsx line 31, remove `password` and `email` from the logged
object. Log only a generic failure message and the error code.
2. In src/lib/auth.tsx, delete the console.log calls that print the restored
session, auth state changes, the loaded profile, and the user objects on
sign-in and sign-up (lines 50, 58, 68, 85, 95). In the console.error at line
46, log only error.message, not the error object or currentUser.
3. Delete the console.log calls in src/pages/Dashboard.tsx,
src/pages/AdminPanel.tsx, src/pages/InvoiceDetail.tsx and
src/pages/PayInvoice.tsx that print invoice, client, account or user records.
4. In api/send-invoice.ts, replace the log at line 54 so it records only
invoiceId, not the recipient address or the issuer profile.
5. In api/stripe-webhook.ts, replace JSON.stringify(event) at line 20 with
event.id and event.type only.
6. In vite.config.ts, add esbuild: { drop: ['console', 'debugger'] } so any
remaining console calls are stripped from production builds.
Never log a password, a token, a session object or a full customer record.
After the AI applies it, check: open the browser console, attempt a sign-in
with a deliberately wrong password, and confirm the password string appears
nowhere in the console output. Sign in successfully and confirm no session or
token object is printed. Load the dashboard and confirm no invoice or client
records appear. Then search src/ and api/ for console. and review whatever
remains line by line.
IP-14 · High · One browser call can write any profile column, including plan and admin status
What we saw
demo-app/src/lib/api.ts:128-138
export async function updateProfile(patch: Partial<Profile>): Promise<Profile> {
const { data, error } = await supabase
.from('profiles')
.update(patch)
.eq('id', currentUserId())
.select()
.single();
...
}
The patch argument is passed to the database unfiltered. Partial<Profile>
permits every column declared in demo-app/src/lib/types.ts:1-10, including
plan and is_admin. TypeScript types are erased at build time and constrain
nobody at runtime.
The row targeted is currentUserId(), the editable localStorage value from
IP-05. With RLS off (IP-02), the database applies no independent check on either
the row or the columns.
Three consequences follow from the same line.
Free upgrades are a supported feature. demo-app/src/pages/Settings.tsx:49-56
implements the paid upgrade as a client-side write with no payment step at all:
async function upgradeToPro() {
try {
await updateProfile({ plan: 'pro' });
await refreshProfile();
} ...
}
The “Upgrade to Pro” button at lines 108-112 sets the paid plan directly. No Stripe call, no charge, no verification. The button is the entitlement.
Self-promotion to administrator. From the browser console,
updateProfile({ is_admin: true }) writes the admin flag, which then satisfies
the check at src/lib/auth.tsx:108.
Writing to other people’s profiles. Setting invoicepilot.uid to another
user’s id first (IP-05) redirects the write to their row.
What it costs you if it triggers
Your paid tier is voluntary. Anyone who reads the code — published in full via the source maps, IP-16 — can take the Pro plan without paying, and so can anyone who presses the upgrade button, since it never charges. For a subscription business this is direct, silent, unmeasurable revenue loss: your plan column says Pro and your payment processor has no matching charge, and nothing reconciles the two.
Beyond billing, the same call grants administrative access, which chains into the whole customer list via IP-10.
How to fix it
- Allowlist the columns a user may change —
full_name,company_name,vat_number— and reject everything else server-side. - Enforce it in the database: the RLS update policy in IP-02 must exclude
planandis_adminvia column-level grants, so the rule holds even if the application is bypassed entirely. planmust be set only by a server handler reacting to a verified Stripe payment event, never by the browser.- Replace
upgradeToProwith a call that starts a real Stripe Checkout session.
Paste-ready prompt
In src/lib/api.ts, updateProfile passes an unfiltered patch object to the
database, letting a user set any profile column including plan and is_admin.
Restrict it.
1. Change updateProfile's signature to accept only an object with the optional
keys full_name, company_name and vat_number. Build the update payload
explicitly from those three fields and ignore anything else in the argument.
Do not spread the caller's object into the update.
2. In src/pages/Settings.tsx, delete the upgradeToPro function and replace the
"Upgrade to Pro" button with a call to a new api/create-checkout-session.ts
endpoint that creates a Stripe Checkout session server-side and redirects to
it. The plan column must only ever be changed by the verified Stripe webhook
handler, never by the browser.
3. In the database migration, add column-level restrictions so the
`authenticated` role has UPDATE permission only on the columns full_name,
company_name and vat_number of public.profiles, not on plan or is_admin.
The database restriction is the real control; the TypeScript signature is only a
convenience and enforces nothing at runtime.
After the AI applies it, check: signed in as an ordinary user, run
updateProfile({ is_admin: true }) and then updateProfile({ plan: 'pro' }) from
the browser console. Both must fail or be silently ignored, and reloading must
show the plan and admin status unchanged. Confirm the legitimate settings form
still saves name, company and VAT number.
IP-15 · High · Deleting invoices and clients runs with the master key and never checks ownership
What we saw
Both destructive operations are executed in the browser through the service-role client, with no ownership condition:
demo-app/src/lib/api.ts:66-69
export async function deleteInvoice(id: number): Promise<void> {
const { error } = await supabaseAdmin.from('invoices').delete().eq('id', id);
if (error) throw error;
}
demo-app/src/lib/api.ts:101-104
export async function deleteClient(id: number): Promise<void> {
const { error } = await supabaseAdmin.from('clients').delete().eq('id', id);
if (error) throw error;
}
The only filter is the row’s id. There is no user_id check, and the
service-role client bypasses row-level rules by design, so even after IP-02 is
fixed these two functions would still delete any row on the platform.
They are called from the interface at
demo-app/src/pages/InvoiceDetail.tsx:79 and
demo-app/src/pages/Clients.tsx:49. The only barrier is a browser confirm()
dialogue (InvoiceDetail.tsx:77, Clients.tsx:47), which is a UI courtesy and
no protection at all.
Because invoice ids are sequential integers (IP-04), deleting every invoice on the platform is a short loop.
The damage is amplified by the schema. invoice_items cascades:
demo-app/supabase/migrations/20240902101500_init_schema.sql:44 declares
invoice_id bigint references public.invoices (id) on delete cascade, so deleting
an invoice silently removes its line items too. payments cascades the same way
(demo-app/supabase/migrations/20240917143000_payments_and_links.sql:5), so
deleting an invoice also destroys the record of money received against it.
What it costs you if it triggers
Permanent, unrecoverable destruction of your financial records, available to anyone with the published key, with no undo in the application.
Invoices are accounting records you are legally obliged to retain, seven to ten years in most EU jurisdictions. Deletion also removes the linked payment records, so you lose not just the invoice but the evidence that it was settled, which is the part you need in a dispute or an inspection.
Recovery depends entirely on database backups, whose existence and retention we could not verify (see What we could not see). Without point-in-time recovery configured in advance, this is gone.
How to fix it
- Move both deletions to server endpoints that verify the caller’s token and confirm ownership before deleting.
- Remove
supabaseAdminfrom browser code (IP-01). - Prefer soft deletion — a
deleted_atcolumn — for financial records, so invoices are hidden rather than destroyed and remain available for the retention period. - Reconsider
on delete cascadeonpayments: a payment record should normally outlive the invoice it settled. - Confirm point-in-time recovery is enabled on the Supabase project.
Paste-ready prompt
In src/lib/api.ts, deleteInvoice and deleteClient use the browser-side
service-role client and filter only by id, so any user can delete any row.
Replace them with ownership-checked server endpoints and soft deletion.
1. Write a migration adding a nullable `deleted_at timestamptz` column to
public.invoices and public.clients.
2. Create api/delete-invoice.ts and api/delete-client.ts. Each must require an
Authorization: Bearer token, verify it with supabase.auth.getUser(), load the
target row, return 403 unless row.user_id matches the verified caller, and
then set deleted_at = now() rather than issuing a DELETE.
3. Rewrite deleteInvoice and deleteClient in src/lib/api.ts to call these
endpoints with the session access token attached. Remove the supabaseAdmin
import.
4. Update listInvoices and listClients to filter out rows where deleted_at is not
null.
Do not perform a hard DELETE on invoices or clients; these are financial records
subject to retention requirements.
After the AI applies it, check: with two accounts, sign in as the first and call
the delete endpoint with the second account’s invoice id — it must return 403 and
the invoice must survive. Confirm deleting your own invoice removes it from the
list but that the row still exists in the database with deleted_at set, and
that its payment records are intact.
Medium findings
IP-16 · Medium · The complete original source code is published alongside the app
What we saw
demo-app/vite.config.ts:9-12
build: {
outDir: 'dist',
sourcemap: true,
},
Source maps are enabled for production builds. The map file exists in the
deployed output — demo-app/dist/assets/index-DMjTepJ_.js.map, 2.7 MB — and the
bundle points browsers to it with a
sourceMappingURL=index-DMjTepJ_.js.map comment.
We confirmed the map contains full original source, not just position mappings.
It carries a sourcesContent array, and its sources list covers all 18
application files, including src/lib/supabase.ts, src/lib/api.ts,
src/lib/auth.tsx and every page. The original TypeScript — comments, variable
names, structure — is recoverable in full by fetching one URL, and browser
developer tools do it automatically.
demo-app/vercel.json:3 sets "outputDirectory": "dist", so the map is served
publicly along with everything else.
This finding, and IP-01’s build evidence, rest on the dist/ we were given
being what production serves. It was built on 4 August 2026, and we could not
compare it against the deployed site (see What we could not see). The
configuration that produces it is the durable part: sourcemap: true will emit a
map on every build until it is changed.
One useful detail for the fix: the map contains the pre-substitution source, so it does not itself contain the service-role token. We searched it and found none. The token is only in the compiled bundle (IP-01). Removing the map does not solve IP-01, and solving IP-01 does not remove the map.
What it costs you if it triggers
Anyone can read your entire codebase as if the repository were public. That
removes the effort of understanding the system from every other finding in this
report: the localStorage identity trick (IP-05), the unauthenticated webhook
(IP-06) and the unfiltered profile update that grants a free Pro plan (IP-14) are
all plainly legible rather than guessed at.
The commercial cost is that whatever is distinctive about how your product works is now copyable.
How to fix it
Set sourcemap: false for production builds. If you want maps for debugging, use
hidden, which generates them without the reference comment, and upload them
privately to your error-reporting tool rather than serving them.
Paste-ready prompt
In vite.config.ts, change the build.sourcemap option from true to false so
production builds do not publish the original source code. If source maps are
wanted for error reporting, use 'hidden' instead of true so the map is generated
without a sourceMappingURL reference in the bundle, and add a build step that
uploads it to the error tracker rather than serving it from dist/.
After the AI applies it, check: rebuild, then confirm dist/assets/ contains no
.map file (or, with hidden, that the bundle contains no sourceMappingURL
comment). Open the deployed site’s developer tools and confirm the Sources panel
shows only minified code.
IP-17 · Medium · API errors return stack traces to the caller, and the interface prints them on screen
What we saw
Both serverless functions return internal error detail in the HTTP response body:
demo-app/api/send-invoice.ts:80-84
return res.status(500).json({
error: err.message,
stack: err.stack,
provider: err.response?.data,
});
demo-app/api/stripe-webhook.ts:52
return res.status(500).json({ error: err.message, stack: err.stack, event });
The webhook echoes the entire submitted event back to the caller, and
provider: err.response?.data returns the raw upstream response from Resend,
which may include account detail.
The browser does the same to the end user.
demo-app/src/components/ErrorBox.tsx:5,10:
const detail = error.stack || (typeof error === 'object' ? JSON.stringify(error, null, 2) : null);
...
{detail && <pre>{detail}</pre>}
This component is rendered on the login page, the public payment page and every
authenticated screen. Supabase errors carry database detail — table names, column
names, constraint names, policy messages — which is therefore printed to whoever
triggered the error, including unauthenticated visitors on /pay/:invoiceId.
What it costs you if it triggers
Error messages become a free map of your system for anyone probing it. Stack traces reveal file paths and internal structure; database errors reveal your schema; the echoed webhook payload confirms exactly which fields the endpoint parses, which shortens the work of exploiting IP-06.
For ordinary users the cost is different but real: a wall of red technical text instead of a usable message is what makes people abandon a payment page.
How to fix it
Log detail on the server; return an opaque message and a correlation id to the caller. In the interface, show a plain sentence and keep the technical detail out of the DOM entirely rather than merely hiding it with CSS.
Paste-ready prompt
Stop leaking internal error detail to callers and users.
1. In api/send-invoice.ts and api/stripe-webhook.ts, remove `stack`, `event` and
`provider` from every error response body. Generate a random correlation id,
log the full error server-side with that id, and return only
{ error: 'Something went wrong', reference: <id> } with the 500 status.
2. In src/components/ErrorBox.tsx, delete the `detail` constant and the <pre>
block that renders it. Render only a short human-readable message. Map known
error shapes to friendly sentences and fall back to a generic one.
The technical detail must not be present in the response body or the DOM at all,
not merely hidden from view.
After the AI applies it, check: force an error on the public payment page by
requesting a non-existent invoice, and confirm the screen shows a plain sentence
with no stack trace, table name or JSON. Force a 500 from /api/send-invoice and
confirm the response body contains no stack key.
IP-18 · Medium · Every API route accepts requests from any website, with credentials, and no security header is set anywhere
What we saw
demo-app/vercel.json:5-14
"headers": [
{
"source": "/api/(.*)",
"headers": [
{ "key": "Access-Control-Allow-Origin", "value": "*" },
{ "key": "Access-Control-Allow-Headers", "value": "*" },
{ "key": "Access-Control-Allow-Credentials", "value": "true" }
]
}
]
The same wildcard is set again in code at demo-app/api/send-invoice.ts:28 and
demo-app/api/stripe-webhook.ts:9.
Access-Control-Allow-Origin: * combined with
Access-Control-Allow-Credentials: true is a contradictory pair that browsers
reject, so the configuration does not achieve what it appears to intend. It does
document an intent to accept authenticated cross-origin calls from anywhere, and
the wildcard origin is applied.
The practical effect today is limited only because the endpoints have no authentication to bypass (IP-06, IP-07). Once authentication is added by those fixes, this header becomes the thing that undermines it.
The headers configured here are also the only headers configured in the whole
project. There is no Content-Security-Policy, no Strict-Transport-Security, no
X-Content-Type-Options, no X-Frame-Options and no frame-ancestors. A
Content-Security-Policy in particular would materially reduce the impact of the
stored XSS in IP-09, and frame-ancestors would prevent the payment page being
framed inside an attacker’s site.
What it costs you if it triggers
Any website a customer visits can make calls to your API from their browser. After IP-06 and IP-07 are fixed to require a session, this header is what would let a malicious page ride that session: the classic cross-site request forgery shape, where merely visiting a page performs actions in your application.
The absence of security headers means that when something does go wrong — an injected script, a framed payment page — none of the standard browser-level defences are in place to contain it.
How to fix it
- Replace the wildcard with your own origin, or drop CORS headers entirely on routes that only your own front end calls from the same origin.
- Remove the CORS header from the Stripe webhook altogether. It is a server-to-server call and no browser is involved.
- Add
Content-Security-Policy,Strict-Transport-Security,X-Content-Type-Options: nosniffandX-Frame-Options: DENY(orframe-ancestors 'none') across all routes.
Paste-ready prompt
In vercel.json, the /api/ routes send Access-Control-Allow-Origin: * together
with Access-Control-Allow-Credentials: true, and no security headers are set
anywhere. Fix both.
1. Replace the wildcard Access-Control-Allow-Origin with the application's own
origin, read from an environment variable. Replace the wildcard
Access-Control-Allow-Headers with an explicit list: Content-Type,
Authorization.
2. Remove the res.setHeader('Access-Control-Allow-Origin', '*') lines from
api/send-invoice.ts and api/stripe-webhook.ts. Remove CORS from the webhook
entirely, including its OPTIONS branch.
3. Add a headers block in vercel.json applying to all routes with:
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Content-Security-Policy with default-src 'self', frame-ancestors 'none', and
connect-src allowing 'self' plus the Supabase project URL.
Verify the app still loads under the CSP before deploying; adjust connect-src and
style-src to what the app genuinely needs rather than loosening to unsafe-inline.
After the AI applies it, check: request the deployed /api/send-invoice and
confirm no wildcard origin appears in the response headers. Load the app with the
console open and confirm no CSP violations are reported and every screen still
functions. A CSP that breaks the app tends to get reverted wholesale, so tune it
rather than remove it.
IP-19 · Medium · The secrets file carries live-mode key formats and is protected only by a rule outside the project
What we saw
A populated demo-app/.env exists in the working tree with eight variables,
including four that must never reach a repository:
VITE_SUPABASE_SERVICE_ROLE_KEY (line 3), STRIPE_SECRET_KEY (line 6),
STRIPE_WEBHOOK_SECRET (line 7) and RESEND_API_KEY (line 9).
demo-app/.gitignore lists eight entries — node_modules, dist, dist-ssr,
*.local, *.tsbuildinfo, .DS_Store, .vercel, npm-debug.log* — and
.env is not among them.
The file is nonetheless untracked today, because the repository that currently
contains demo-app/ carries .env in its own root .gitignore, and that
pattern matches at any depth. We confirmed with git status that .env is
neither tracked nor reported as an untrackable addition, and with
git log --all --full-history that no .env has ever been committed anywhere in
this repository’s history.
The protection is therefore accidental, and it is one directory move from
gone. demo-app/ is a self-contained Vite project with its own
package.json, vercel.json and .gitignore; the moment it is extracted into
its own repository — which is what deploying it to Vercel would normally mean —
the parent rule stops applying and .env becomes trackable on the next commit.
Note also the key formats: demo-app/.env:5 uses pk_live_ and line 6 uses
sk_live_. These are Stripe live-mode prefixes, the real-money keys, rather
than the pk_test_/sk_test_ forms appropriate to a development environment. In
this demonstration application the values are placeholders and valid nowhere, as
demo-app/README.md:6 states.
demo-app/.env.example exists and lists the same eight variable names with empty
values, which is the right pattern. It does not help while the populated sibling
is unlisted in the project’s own ignore file.
What it costs you if it triggers
A committed secret is effectively permanent. It survives in history after deletion, it is copied to every clone and fork, and it is harvested within minutes by scanners that watch public repositories continuously. Recovery is not deletion of the file but rotation of every key it contained, plus history rewriting.
The specific hazard here is that the safety net is invisible. Nothing in
demo-app/ says .env is ignored, so anyone reading this project on its own
terms would reasonably conclude it is protected when it is not.
Using live-mode Stripe keys outside production means routine development mistakes move real money and touch real customer payment records.
How to fix it
- Add
.env,.env.localand.env.*.localtodemo-app/.gitignore, keeping!.env.example, so the project protects itself wherever it is checked out. - Use test-mode Stripe keys everywhere except production.
- Keep real values only in the Vercel environment-variable store.
- Remove the
VITE_prefix from every secret (IP-01). The naming is what publishes them to the browser regardless of git.
Paste-ready prompt
Add the following entries to demo-app/.gitignore, keeping the existing lines:
.env
.env.local
.env.*.local
!.env.example
Then update demo-app/README.md so the environment variable table no longer
describes VITE_SUPABASE_SERVICE_ROLE_KEY as being used by the app. It is a
server-only secret and must be named SUPABASE_SERVICE_ROLE_KEY without the VITE_
prefix. Also correct the table so STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET and
RESEND_API_KEY are marked as server-only.
After the AI applies it, check: run git check-ignore -v demo-app/.env and
confirm the rule reported is the one in demo-app/.gitignore, not the parent’s.
Then copy demo-app/ to a fresh directory, run git init inside it, and confirm
git status does not offer .env.
IP-20 · Medium · The login form confirms whether an email address has an account
What we saw
Before attempting a sign-in, the login page asks the database whether the address exists:
demo-app/src/lib/auth.tsx:23-30
export async function emailIsRegistered(email: string): Promise<boolean> {
const { data } = await supabase
.from('profiles')
.select('id, email, plan')
.eq('email', email.trim().toLowerCase())
.maybeSingle();
return Boolean(data);
}
demo-app/src/pages/Login.tsx:22-26
const known = await emailIsRegistered(email);
if (!known) {
setError(new Error(`We couldn't find an InvoicePilot account for ${email}.`));
return;
}
The application therefore states plainly, before any password is checked, whether a given address holds an account.
The query runs against the profiles table with the public anon key, and because
RLS is off (IP-02) it succeeds for anyone. The same table is fully readable, so
the enumeration is available both through this form and directly over the REST
API, where a single unauthenticated request returns every user’s email, name,
company, VAT number, plan and admin flag.
Note the query also selects plan, which reveals which accounts are paying.
What it costs you if it triggers
It converts a password-guessing problem into a much easier one. An attacker learns which addresses are worth attempting before trying a single password, and can confirm whether a list of addresses leaked elsewhere maps onto your platform. Combined with credential-stuffing against reused passwords, this is the usual route to account takeover.
It also leaks your customer list — a competitor can test whether specific
businesses use your product — and, via plan, which of them pay.
How to fix it
- Delete the pre-flight existence check. Call the sign-in directly and show one identical message for both wrong-address and wrong-password cases.
- Fix IP-02 so
profilesis not readable by the anonymous role at all. - Add rate limiting on sign-in attempts.
Paste-ready prompt
The login flow leaks whether an email address has an account. Remove the leak.
1. Delete the emailIsRegistered function from src/lib/auth.tsx entirely.
2. In src/pages/Login.tsx, remove the call to emailIsRegistered and the
associated "We couldn't find an InvoicePilot account for ..." error branch.
Call signIn directly.
3. In the catch block, replace the raw error with a single generic message:
'Email or password is incorrect.' Use the same message regardless of whether
the address exists or the password was wrong.
4. Make sure the sign-up form does not leak the same information through a
different message.
After the AI applies it, check: attempt sign-in with an address that does not
exist and with a real address plus a wrong password. Both must produce the
identical message, and the two responses should take a comparable amount of time.
Confirm a search for emailIsRegistered across src/ returns nothing.
IP-21 · Medium · The webhook is not idempotent, never reconciles the amount, drops events silently, and keeps the whole payload
What we saw
Four defects in the same handler, all distinct from the missing signature check in IP-06.
No idempotency. demo-app/api/stripe-webhook.ts:31-39 inserts a payment row
for every accepted event. provider_event_id is stored (line 34) but the column
has no unique constraint —
demo-app/supabase/migrations/20240917143000_payments_and_links.sql:7 declares it
as a plain provider_event_id text — and nothing checks whether that event was
already processed. Payment providers deliver at least once by design and retry on
any non-2xx response.
No amount reconciliation. At lines 41-44 the invoice is marked paid without
comparing what was received to what was owed. The amount recorded at line 35
comes from the event (object.amount_total ?? object.amount ?? 0). Note the
?? 0 fallback, which will happily record a payment of zero and still mark the
invoice settled.
Silent skip. Lines 26-28 return HTTP 200 with
{ received: true, skipped: 'no invoice reference' } when no invoice id is
present. Returning 200 tells the provider the event was handled successfully, so
it is never retried. A genuine payment whose metadata is missing is dropped with
no error, no alert and no record.
The entire event is retained. Line 38 writes raw_payload: event into the
payments table, whose column is raw_payload jsonb
(.../20240917143000_payments_and_links.sql:11). A Stripe event carries the
payer’s name, email and billing address, so this is a second copy of your
customers’ customers’ personal data, in a table that anon holds grant all on
(IP-02) and that listPayments reads with select('*')
(demo-app/src/lib/api.ts:117-126).
What it costs you if it triggers
Your books stop matching your bank. Duplicate delivery inflates recorded revenue
with payments that never happened twice, so your dashboard
(demo-app/src/pages/Dashboard.tsx:46) overstates collections and you cannot tell
which rows are real.
Missing amount reconciliation means an invoice is marked fully paid by a partial payment, or by a zero. Combined with IP-06, this is what makes fraudulent settlement trivial rather than merely possible.
The silent skip is the one that costs you money you actually earned: a customer pays, the event lacks metadata, the invoice stays open, and you chase a client who has already paid.
The retained payload is a data-protection problem rather than a money one. You are storing payment-processor personal data indefinitely, with no retention rule, in a table that is readable by the anonymous role today.
How to fix it
- Add a unique constraint on
payments.provider_event_idand treat a conflict as “already processed, return 200”. - Compare the received amount to the invoice total including tax before setting
paid. Record partial payments without settling the invoice. - Remove the
?? 0fallback. A missing amount is an error, not a zero. - When an event cannot be matched to an invoice, record it in a dead-letter table and raise an alert rather than discarding it.
- Stop storing the whole event. Keep the fields you need — event id, type, amount, currency, last four digits — and drop the rest.
Paste-ready prompt
Make the Stripe webhook handler in api/stripe-webhook.ts correct, in addition to
verifying its signature.
1. Write a migration adding a unique constraint on
public.payments(provider_event_id), and a new table public.webhook_failures
(id, event_id, event_type, reason text, created_at) for events that cannot be
matched.
2. In the handler, insert the payment and handle a unique-violation error as a
success: the event was already processed, so return 200 without applying it a
second time.
3. Before marking the invoice paid, load the invoice and compute its total
including tax. Mark it paid only if the received amount is greater than or
equal to that total. Otherwise record the payment and leave the invoice open.
4. Remove the `?? 0` fallback on the amount. If no amount is present, record a
webhook_failures row and return 400.
5. When no invoice reference is found, insert a webhook_failures row instead of
returning 200 with a 'skipped' message.
6. Stop writing raw_payload. Store only event id, event type, amount_cents,
currency and payer_email. Write a migration that drops the raw_payload column
after confirming nothing reads it.
After the AI applies it, check: replay the same signed event twice with the
Stripe CLI — exactly one payment row must exist and the invoice total must not
double. Send an event whose amount is below the invoice total and confirm the
invoice stays open. Send one with no invoice reference and confirm a
webhook_failures row appears rather than a silent 200. Confirm no payments row
contains a customer address.
IP-22 · Medium · Invoice numbers come from one global sequence that anyone can advance
What we saw
demo-app/supabase/migrations/20240902101500_init_schema.sql:54-61
create sequence if not exists public.invoice_number_seq start 1001;
create or replace function public.next_invoice_number()
returns text
language sql
as $$
select 'INV-' || lpad(nextval('public.invoice_number_seq')::text, 4, '0');
$$;
There is one sequence for the entire platform, shared by every business using it.
It is called on invoice creation at demo-app/src/lib/api.ts:32
(await supabase.rpc('next_invoice_number')).
Line 66 of the same migration grants execute on all functions in the schema to
anon and authenticated, so the function can be called directly by anyone,
repeatedly, without creating an invoice.
Two consequences follow. Cross-tenant leakage: because the sequence is global, each business’s invoice numbers are non-contiguous, and the size of the gaps reveals how many invoices every other business on the platform issued in between. A competitor invoicing through the same product can measure your volume from their own numbering. Gap injection: anyone can call the RPC in a loop to burn thousands of numbers, so legitimate invoices jump arbitrarily.
Invoice numbering is not cosmetic. Sequential, gap-free numbering per issuer is a legal requirement in most EU jurisdictions, Portugal included, and gaps are exactly what a tax inspection asks about, the presumption being a deleted or suppressed invoice.
What it costs you if it triggers
Your invoice numbering does not satisfy the rules it exists to satisfy. You cannot explain the gaps, because the missing numbers were issued to other businesses or consumed by a stranger calling a public function. In an inspection, unexplained gaps in an invoice sequence invite the assumption that invoices were suppressed, which shifts the burden of proof onto you.
The competitive leakage is smaller but real: your monthly invoice volume is inferable by anyone else on the platform.
How to fix it
- Number invoices per issuer, not globally — a
(user_id, year, counter)arrangement, or a per-user sequence. - Allocate the number inside the same transaction that inserts the invoice, so a number cannot be consumed without an invoice existing.
- Revoke execute on the function from
anon. - Replace the blanket
grant execute on all functionswith explicit grants.
Paste-ready prompt
Invoice numbers in this Supabase project come from a single global sequence that
any caller can advance, which leaks volume between tenants and creates gaps in a
legally-required sequence. Replace it with per-issuer numbering.
1. Write a migration creating a table public.invoice_counters
(user_id uuid primary key, year int not null, last_number int not null
default 0).
2. Replace public.next_invoice_number() with a function that takes no arguments,
is SECURITY DEFINER, sets search_path explicitly, derives the user from
auth.uid(), and atomically increments that user's counter for the current year
(using INSERT ... ON CONFLICT DO UPDATE ... RETURNING), returning a number
formatted as 'INV-<year>-<0000>'.
3. Revoke execute on the function from anon; grant it only to authenticated.
4. Replace the blanket `grant execute on all functions in schema public to anon,
authenticated` from the initial migration with explicit per-function grants.
5. Allocate the number inside the invoice insert so a number cannot be consumed
without an invoice being created.
After the AI applies it, check: create invoices from two different accounts alternately and confirm each account sees its own unbroken sequence rather than interleaved numbers. Confirm calling the RPC with the anon key alone is refused. Confirm concurrent creation from the same account produces no duplicate numbers.
IP-23 · Medium · Nothing validates amounts, tax rates or text length
What we saw
Invoice amounts are parsed from free-text inputs with no validation at all:
demo-app/src/pages/Invoices.tsx:47-48
amount_cents: Math.round(parseFloat(amount) * 100),
tax_rate: parseFloat(taxRate),
The amount field is a plain text input with no type="number", no min and no
pattern (demo-app/src/pages/Invoices.tsx:107-112), and the tax field likewise
(line 116).
parseFloat('') and parseFloat('abc') both return NaN, so a blank or
non-numeric entry produces Math.round(NaN) — NaN — which is then sent to an
integer column. Negative amounts pass through unchallenged, as do absurd values
and tax rates above 100.
Nothing enforces limits anywhere else either. There is no length constraint on
notes, name, address or company in the schema
(demo-app/supabase/migrations/20240902101500_init_schema.sql:14-48 — all plain
text), no server-side validation of any kind because there is no server in
these paths, and no check that client_id belongs to the caller before it is
stamped onto an invoice (demo-app/src/lib/api.ts:38).
What it costs you if it triggers
Bad numbers reach your accounts. A negative invoice silently reduces reported
revenue; a NaN either fails confusingly or writes a broken row that makes
totals stop computing on the dashboard, and the user’s response to a
NaN-shaped error is to distrust the whole product.
Unbounded text fields are a storage and cost problem, and a delivery problem once
those fields are interpolated into emails (IP-07). The unvalidated client_id
means an invoice can be attached to another user’s client record while RLS is
off.
How to fix it
- Validate before submitting: amount must be a finite number greater than zero; tax rate must be between 0 and 100.
- Enforce it in the database with
CHECKconstraints, so the rule holds regardless of which client writes. - Add length limits on the text columns.
- Use a schema validator for form input rather than hand-written checks.
Paste-ready prompt
Add input validation to invoice creation, in both the UI and the database.
1. Write a migration adding CHECK constraints:
- invoices.amount_cents >= 0
- invoices.tax_rate >= 0 and <= 100
- length(invoices.notes) <= 2000
- length(clients.name) <= 200, length(clients.address) <= 500,
length(clients.company) <= 200
2. In src/pages/Invoices.tsx, validate before calling createInvoice: parse the
amount and tax rate, and reject with an inline field-level message if either
is NaN, negative, or if the tax rate exceeds 100. Do not submit the form when
validation fails.
3. Change the amount and tax inputs to type="number" with appropriate min, max
and step attributes.
4. Add the same validation to createInvoice in src/lib/api.ts so it cannot be
called with invalid values from anywhere else.
The database CHECK constraints are the real enforcement; the UI validation is for
user experience.
After the AI applies it, check: submit the invoice form with the amount blank,
with abc, and with -500. Each must show a clear field-level message and create
nothing. Confirm a direct database insert with amount_cents = -1 is rejected by
the constraint. Confirm a valid invoice still saves normally.
IP-24 · Medium · Money moves in several writes that are not a transaction
What we saw
Two paths in this application change more than one thing, and neither of them groups the changes.
The webhook. demo-app/api/stripe-webhook.ts:31-39 inserts a payment row,
and then, as a separate statement at lines 41-44, marks the invoice paid. These
are two independent requests to Supabase. If the second fails — a network blip, a
constraint, a cold start timing out — a payment is recorded against an invoice
that still reads unpaid, and nothing retries, because the handler’s own error
path (line 52) returns 500 without undoing the insert.
The email path. demo-app/api/send-invoice.ts:56-70 sends the mail through
Resend and then, at lines 72-75, updates the invoice to sent and increments
reminder_count. If that update fails, the customer has your invoice and your
system does not know it was sent. The increment itself is a read-then-write:
reminder_count: (invoice?.reminder_count || 0) + 1 takes the value read at line
41 and writes it back. Two sends that overlap both read the same number and both
write the same result, so one of them is lost.
The interface then performs a third write over the top:
demo-app/src/pages/InvoiceDetail.tsx:69 calls changeStatus('sent') from the
browser after the server has already set the same field, so a single “Email
invoice” click produces two writes to invoices.status from two different places.
What it costs you if it triggers
The state of an invoice becomes something you have to reconstruct rather than read. A payment with no matching settlement, or a settlement with no matching payment, is the shape of every reconciliation project: somebody exports two lists and compares them by hand.
Under normal load the windows are small and the symptom is rare, which is the problem. Rare, silent inconsistency in financial records is discovered late, in bulk, and usually by an accountant rather than by you.
How to fix it
- Put the payment insert and the invoice update in one database transaction, or
move both into a single
SECURITY DEFINERPostgres function the handler calls once, so either both happen or neither does. - Update the invoice before sending the email, or record a “send attempted” row first and reconcile it against the provider’s response.
- Make
reminder_countan atomic increment in SQL rather than a read, add one and write. - Remove the duplicate client-side
changeStatus('sent')call. One writer per field.
Paste-ready prompt
Two money-path operations in this project perform several writes that are not
grouped, and one of them is a lost-update race. Fix all three.
1. Write a migration adding a SECURITY DEFINER Postgres function
public.record_payment(p_invoice_id bigint, p_event_id text, p_amount_cents int,
p_currency text, p_payer_email text) that inserts the payments row and updates
the invoice's status and paid_at in one statement block, and sets search_path
explicitly. Grant execute to no one except the service role.
2. Rewrite api/stripe-webhook.ts to call that function once instead of issuing a
separate insert and update.
3. In api/send-invoice.ts, move the invoice update above the Resend call, and
change the reminder_count update to an atomic SQL increment
(reminder_count = reminder_count + 1) rather than reading the value and
writing value + 1. If the send then fails, roll the status back or record the
failure.
4. In src/pages/InvoiceDetail.tsx handleSend, delete the changeStatus('sent')
call. The server already sets the status; refresh the invoice from the server
instead.
After the AI applies it, check: with the invoice update deliberately made to
fail (point it at a non-existent id in a scratch copy), confirm no payments row is
left behind. Fire two “Email invoice” clicks in quick succession and confirm
reminder_count reads 2, not 1.
IP-25 · Medium · The invoice total is computed in two places and stored in none
What we saw
An invoice stores amount_cents integer and tax_rate numeric
(demo-app/supabase/migrations/20240902101500_init_schema.sql:33-34). Storing
money as an integer count of minor units is correct, and worth saying, because it
is the thing this application gets right about money.
The total, however, is never stored. It is recomputed wherever it is needed, from two separate implementations of the same rule:
demo-app/src/lib/format.ts:29-31
export function totalWithTax(amountCents: number, taxRate: number): number {
return Math.round(amountCents * (1 + (taxRate || 0) / 100));
}
demo-app/api/send-invoice.ts:12
const total = Math.round(invoice.amount_cents * (1 + (invoice.tax_rate || 0) / 100)) / 100;
The browser copy is called at PayInvoice.tsx:33, InvoiceDetail.tsx:132,
Invoices.tsx:167, and three times in Dashboard.tsx — at :45 and :46 for
the outstanding and collected figures, and again at :138 for the per-row
total. The server copy exists only to build the email.
Three things follow. The rounding is applied at each display site rather than
once at a defined point, so the invoice, the email and the dashboard each round
independently. The rule exists twice, so a change to how tax is applied — a
different rounding convention, a second tax component, a discount — has to be
made in two files, and the second one will be missed. And because the total is
never written down, an invoice issued last year is recomputed with today’s code:
change totalWithTax and every historical invoice silently restates.
The invoice_items table
(demo-app/supabase/migrations/20240902101500_init_schema.sql:42-48) is declared
with quantity and unit_price_cents but is never read or written by any code
in src/ or api/, so an invoice here has no line detail at all behind its
single amount.
What it costs you if it triggers
An invoice is a statement of what was owed on the day it was issued. If the number is derived rather than recorded, you cannot reproduce a historical invoice, which is the one thing an accountant and a tax inspection both ask for.
The duplication is the more likely failure in practice. The first time the tax rule changes, the emailed total and the on-screen total disagree, and the person who notices is your customer.
How to fix it
- Compute the total once, at issue time, and store it —
total_cents, plustax_centsif you need the split. Everything else reads the stored value. - Delete the duplicate implementation in
api/send-invoice.tsand import the shared one, or read the stored total. - Decide whether line items are part of the product. If they are, populate
invoice_itemsand deriveamount_centsfrom it. If they are not, drop the table so the schema stops promising something the application does not do.
Paste-ready prompt
The invoice total in this project is recomputed at every display site from two
duplicated implementations, and is never stored. Fix it.
1. Write a migration adding `total_cents integer` and `tax_cents integer` to
public.invoices, and backfill both from the existing amount_cents and
tax_rate.
2. Compute and write both columns in createInvoice and in any update that changes
amount_cents or tax_rate. Keep totalWithTax in src/lib/format.ts as the single
implementation used to compute them.
3. Change every display site to read invoice.total_cents instead of calling
totalWithTax: src/pages/PayInvoice.tsx, src/pages/InvoiceDetail.tsx,
src/pages/Invoices.tsx and src/pages/Dashboard.tsx.
4. In api/send-invoice.ts, delete the inline total calculation at line 12 and use
invoice.total_cents.
There must be exactly one place in this codebase where a total is calculated, and
the result must be persisted with the invoice.
After the AI applies it, check: create an invoice, note the total, then change
totalWithTax to add one cent and reload. The existing invoice’s total must not
move; a newly created one must. Confirm the emailed total and the on-screen total
match to the cent for an invoice with a fractional tax rate such as 23.5.
IP-26 · Medium · Deleting an account destroys its invoices, its clients and their payment history
What we saw
Every table hangs off auth.users with a destructive cascade:
demo-app/supabase/migrations/20240902101500_init_schema.sql:4—id uuid primary key references auth.users (id) on delete cascadeonprofiles:16—user_id uuid references auth.users (id) on delete cascadeonclients:28— the same oninvoices
and the child tables cascade off those in turn: invoice_items from invoices
(:44) and payments from invoices
(demo-app/supabase/migrations/20240917143000_payments_and_links.sql:5).
So a single delete of one row in auth.users removes that account’s profile,
every client it ever billed, every invoice it ever issued, every line item and
every payment record against those invoices. There is no soft delete anywhere in
the schema, and no archival copy.
There is no account-deletion feature in the application, which is what keeps this at Medium rather than higher. The delete is reachable from the Supabase dashboard, from the Auth admin API, and — while IP-01 stands — by anyone holding the published service-role key.
What it costs you if it triggers
Financial records vanish on an operation that reads like housekeeping. “Remove this test account” and “remove seven years of accounting evidence” are the same click, with no warning and no undo.
The cost is worst in the case that is most likely to happen: a customer asks you to delete their account under GDPR, you comply, and you have simultaneously destroyed invoices you are separately obliged to retain. Erasure and retention pull in opposite directions here, and the schema has already picked a side.
How to fix it
- Change the invoice and payment relationships so financial records survive the
account.
on delete restrictoninvoices.user_id, or a nullableuser_idwithon delete set nullplus a denormalised copy of the issuer’s details on the invoice itself. - Never cascade
paymentsfrominvoices. A payment record should outlive the invoice it settled. - Implement account closure as a soft delete plus anonymisation of the personal fields, keeping the accounting records.
- Confirm point-in-time recovery is enabled before any of this is exercised.
Paste-ready prompt
In this Supabase schema, deleting a row in auth.users cascades away the account's
profile, clients, invoices, invoice_items and payments. Financial records must
survive account deletion. Write a migration that:
1. Drops the foreign key on public.invoices.user_id and recreates it as
`references auth.users (id) on delete restrict`.
2. Drops the foreign key on public.payments.invoice_id and recreates it as
`on delete restrict`.
3. Adds issuer_snapshot jsonb to public.invoices holding the issuer's company
name, VAT number and address as they were at issue time, and backfills it from
the current profiles rows.
4. Adds `closed_at timestamptz` to public.profiles for soft account closure.
Do not change the cascade on invoice_items; line items belong to their invoice.
After the AI applies it, check: in a staging project, create an account with an
invoice and a payment, then delete the auth user. The delete must be refused. Set
closed_at instead and confirm the invoice and its payment remain readable, with
the issuer’s details still present on the invoice.
IP-27 · Medium · There is no error tracking, and failures are designed to be silent
What we saw
The only instrumentation in the codebase is console logging: seventeen calls
across src/ and api/, catalogued under IP-13. There is no error-reporting
service, no alerting, no health check and no structured logging. Nothing in
demo-app/package.json provides monitoring, and nothing in demo-app/vercel.json
configures it.
Browser-side failures are shown to the user and then discarded. Every page
handles errors by setting local state: .catch(setError) at
demo-app/src/pages/Dashboard.tsx:38, demo-app/src/pages/Clients.tsx:22,
demo-app/src/pages/AdminPanel.tsx:20, demo-app/src/pages/PayInvoice.tsx:20
and demo-app/src/pages/InvoiceDetail.tsx:36. Nobody is told.
Some failures are actively hidden. demo-app/src/pages/Settings.tsx:25 ends
.catch(console.error). demo-app/api/stripe-webhook.ts:26-28 returns HTTP 200
for an unmatched event, so Stripe records success and never retries (IP-21).
The gap that matters most is that the money path is unobserved. If the webhook
starts failing — a bad deploy, a rotated secret, a changed payload — invoices
stop flipping to paid. There is no alert, and the symptom, an invoice
sitting in sent, is indistinguishable from a customer who has not paid yet. You
would discover it when a customer complains about being chased for a settled
invoice.
There is also no React error boundary anywhere in demo-app/src/main.tsx or
demo-app/src/App.tsx, so an unhandled render error blanks the page with no
message and no report.
What it costs you if it triggers
You find out about problems from your customers, which means every incident starts with reputational damage already done and runs for as long as it takes someone to complain.
For payment processing specifically, the detection gap converts a short outage into a reconciliation project. By the time it surfaces you must work out which payments arrived during the broken window and repair each invoice by hand, against customers who are already annoyed.
Silent failure also makes every other finding harder to detect. Nothing in this system would show you the mass invoice deletion in IP-15 or the enumeration in IP-04: no unusual pattern, no alert, no record.
How to fix it
- Add an error-reporting service covering both the browser app and the serverless functions, configured to scrub credentials (IP-13).
- Alert on webhook failures specifically, and monitor the provider’s own webhook failure view.
- Stop returning 200 for events you did not process (IP-21).
- Add a React error boundary so a render failure shows a message and reports itself.
- Track a business metric — invoices marked paid per day — so a payment path that silently stops is visible as a number going to zero.
This finding is a set-up task rather than a code edit, so we do not give a fix
prompt. The sequence is: choose a provider, add its SDK to both the browser entry
point and the api/ handlers, configure a scrubbing hook on the way out, then
remove the console calls in IP-13 once real reporting is in place.
After it is done, check: deliberately break the webhook in staging and confirm an alert reaches a human within minutes, without anyone looking at a dashboard.
IP-28 · Medium · There are no automated checks: the lint script cannot run, there are no tests, no CI, and the server code is not type-checked
What we saw
demo-app/package.json:10 defines "lint": "eslint .", but eslint is not a
dependency of this project. It appears in neither dependencies nor
devDependencies, there is no eslint configuration file inside demo-app/,
and it is not present in the installed node_modules. A configuration does
exist in the repository that currently contains demo-app/, which flat-config
lookup would walk up to — but that is the surrounding repository’s, not this
project’s, and it disappears the moment this project is extracted to be
deployed. The script
cannot execute. It has presumably never run.
There are no tests. We searched the tree for any *.test.* or *.spec.* file and
found none, and no test runner is declared.
There is no continuous integration: no .github/ directory, no workflow
definition, no pre-commit hook. The only dotfiles in the project are .gitignore,
.env and .env.example.
The build does type-check, but not all of it. demo-app/package.json:8 runs
tsc -b && vite build, and demo-app/tsconfig.json:19 sets
"include": ["src"]. The api/ directory is outside that. Both serverless
functions — the only server-side code in the product, the ones that handle
payment events and send mail — are never type-checked, and both are written
almost entirely in any: req: any, res: any, err: any, and
invoiceHtml(invoice: any, issuer: any) at
demo-app/api/send-invoice.ts:11. strict: true at tsconfig.json:14 buys
nothing there.
The consequence is that nothing stands between a change and production. Every
finding in this report reached the deployed build unchallenged, and the one that
a check would have caught for free — the exposed key in IP-01 — needs only a
build step that decodes the tokens in dist/.
This matters more than usual for a codebase authored largely by AI tooling.
Generated changes arrive fluent, plausible and untested, and fluency is not
correctness. The migration comment at
demo-app/supabase/migrations/20241004091200_settings_and_dashboard_fix.sql:17-18
— disabling row security to make a symptom go away — is exactly the kind of change
a review gate exists to stop, and it went straight in.
What it costs you if it triggers
Defects reach production at the speed you can generate them, and you learn about them from customers. As the codebase grows, each change carries an increasing chance of breaking something invisible, and without tests the only way to find out is to ship it.
The specific business risk is regression on the money path. Nothing would tell you that a change broke invoice totals, tax calculation or payment reconciliation until the numbers were already wrong in front of customers.
How to fix it
- Install and configure eslint so the existing script runs, with the TypeScript and React plugins.
- Bring
api/into the type-check and give the handlers real types. - Add a test runner and write tests for the logic where errors cost money first:
totalWithTax,formatMoney,formatNotesescaping (IP-09) and the payment-URL validator (IP-12). - Add a CI workflow that runs the type-check, the linter, the tests and a dependency advisory check on every change, and blocks merge on failure.
- Add a build-time check that fails if the bundle contains a token whose decoded
payload includes
service_role: the check from IP-01, automated so the worst finding in this report cannot recur.
Paste-ready prompt
This project has a lint script but no eslint installed, no tests, no CI, and its
api/ directory is excluded from the type-check. Set up the missing tooling.
1. Add eslint with @typescript-eslint and eslint-plugin-react-hooks as
devDependencies and create a flat config appropriate to a Vite + React +
TypeScript project. Confirm `npm run lint` runs.
2. Add a second tsconfig for the api/ directory with the Node types, include it
in the build, and replace the `any` annotations in api/send-invoice.ts and
api/stripe-webhook.ts with real request, response and payload types.
3. Add vitest as a devDependency with a "test" script. Write unit tests for
src/lib/format.ts covering totalWithTax, formatMoney, daysUntil and
formatNotes, including a test asserting that formatNotes escapes HTML.
4. Create .github/workflows/ci.yml running on push and pull request: install
dependencies, then run the type-check, `npm run lint`, `npm test` and
`npm audit --audit-level=high`. Fail the build on any failure.
5. Add a script scripts/check-bundle-secrets.mjs that scans dist/assets/*.js for
JWT-shaped strings, base64-decodes each token's payload segment, and exits
non-zero if any decoded payload contains "service_role". Add it to the CI
workflow after the build step.
Step 5 must decode the tokens rather than grep for the literal string, because
the role name does not appear in plain text in the bundle.
After the AI applies it, check: run the lint and test scripts locally and confirm both execute. Then deliberately reintroduce a service-role key into a build and confirm the bundle-secret script fails. A guard that never fires on a real problem is worse than no guard, because it is trusted.
IP-29 · Medium · Two dependencies are frozen at exact versions from 2019 and 2021, and nothing has ever checked them
What we saw
demo-app/package.json:14-15
"axios": "0.21.1",
"lodash": "4.17.15",
Both are pinned to an exact old version with no caret, while every other
dependency in the file uses a caret range. We confirmed against
demo-app/package-lock.json that these exact versions are what resolve: axios
0.21.1 and lodash 4.17.15. For comparison, several caret-ranged entries have
floated well past their declared minimum: @supabase/supabase-js resolves to
2.112.0, typescript to 5.9.3, @vitejs/plugin-react to 4.7.0. Others have
not moved at all — react and react-dom sit at 18.3.1, the version their
range names. Whether any resolved version is the latest published is a registry
question this audit did not ask. So the lockfile is
otherwise recent, and these two are frozen deliberately rather than by neglect of
the whole tree.
Both are reachable. axios is used server-side at
demo-app/api/send-invoice.ts:1 and :56 for the outbound Resend call, and that
handler is publicly callable by anyone (IP-07), so its request path is
attacker-reachable. lodash is bundled into the browser via
demo-app/src/pages/Dashboard.tsx:3 (import { groupBy, sumBy } from 'lodash').
The exact-pinning of precisely the two stale packages, while everything else floats, is characteristic of AI-generated dependency lists reproducing versions that were current when the code was written.
We did not check either version against published advisories. Doing so requires a request to a vulnerability database or a package registry, and this audit makes no network requests. That check is named in What we could not see and it is the first thing to run after this report. Both packages are widely used, long-lived and heavily audited by the ecosystem, which means an advisory check is cheap and likely to return something for versions this old — but we are not willing to state what, having not run it.
What it costs you if it triggers
Two of your dependencies have been frozen since before your product existed, and the freeze is the mechanism: a caret range would have picked up every patch release automatically, and an exact pin prevents the ecosystem from ever fixing them on your behalf.
Whatever has been found in those versions since, you still have. That exposure is opportunistic rather than targeted, because automated scanners find declared dependency versions from the outside without any knowledge of your application. You do not need to be worth attacking.
There is also a commercial cost. Any customer security questionnaire or enterprise procurement review runs exactly this check, and known-vulnerable dependencies with published fixes available are the hardest finding to explain.
How to fix it
- Run an advisory check against the lockfile as the first action, and let its output set the urgency of the rest of this finding.
- Upgrade
axiosto the current 1.x release. The API differences from 0.21 are small, and the single call site inapi/send-invoice.tsis straightforward to verify. - Remove
lodashaltogether. It is used for two functions that are a few lines of plain JavaScript, and dropping it removes the exposure and roughly 70 KB from the bundle (see IP-33). - Move both to caret ranges so patch releases are picked up.
- Add the advisory check to CI so this is caught automatically rather than at audit time (IP-28).
Paste-ready prompt
This project pins axios 0.21.1 and lodash 4.17.15 while every other dependency
uses a caret range. Both versions are years old. Fix both.
1. Run `npm audit` and report what it says about axios and lodash before
changing anything.
2. Upgrade axios to the latest 1.x version using a caret range. Then review
api/send-invoice.ts, which is the only usage: confirm the axios.post call and
its error handling still work under 1.x, particularly the shape of
err.response.data.
3. Remove lodash and @types/lodash from package.json entirely. In
src/pages/Dashboard.tsx, replace the imported groupBy and sumBy with small
local helper functions using native Array.prototype.reduce, preserving the
existing behaviour exactly.
4. Run `npm audit` again and report what remains.
Do not pin exact versions for these; use caret ranges so patch fixes apply.
After the AI applies it, check: run the advisory check and confirm the entries
for axios and lodash are gone. Confirm a search for lodash across src/ and
package.json returns nothing. Then load the dashboard and check the “Collected
per month” chart and the outstanding/collected totals show the same figures as
before the change. The groupBy/sumBy replacement is the part most likely to be
subtly wrong.
Low findings
IP-30 · Low · A privileged database function runs without a fixed search path
What we saw
demo-app/supabase/migrations/20241004091200_settings_and_dashboard_fix.sql:26-36
defines handle_new_user() as security definer, meaning it executes with the
privileges of its owner rather than the caller, but it does not set search_path:
create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer
as $$
begin
insert into public.profiles (id, email, full_name)
values (new.id, new.email, coalesce(new.raw_user_meta_data ->> 'full_name', ''));
return new;
end;
$$;
It is attached as a trigger on auth.users at lines 38-41.
Without an explicit search_path, the names the function resolves depend on the
caller’s setting. A user able to create objects in a schema earlier in that path
could shadow a referenced object and have their own code run with the function
owner’s privileges. Supabase’s own database linter flags this pattern.
Exploitation requires object-creation rights an ordinary user should not have, which is why this is Low rather than higher. The blanket grants in IP-02 make the surrounding privilege model loose enough that it is worth correcting while the migrations are being revised.
What it costs you if it triggers
Someone who already had a foothold in your database would be able to widen it to full ownership, which turns a contained incident into an unbounded one. That is the whole cost: it does not open a new door, it removes the ceiling on damage from a door somebody else opened.
The nearer-term cost is smaller and more certain. This pattern is on the standard warning list that Supabase’s own linter emits, so it appears in every routine health check of the project and in any security review a customer runs, where an open warning with a one-line fix is an awkward thing to still be carrying.
How to fix it
Add set search_path = public, pg_temp to the function definition. Apply the same
to next_invoice_number() when it is rewritten under IP-22 and to
record_payment() if IP-24 is implemented as suggested.
Paste-ready prompt
In a new Supabase migration, redefine public.handle_new_user() exactly as it is
now but add `set search_path = public, pg_temp` to the function definition, after
the `security definer` clause. Do not change its body or the trigger. Apply the
same setting to any other SECURITY DEFINER function in the schema.
After the AI applies it, check: run the Supabase database linter and confirm the
mutable-search-path warning for handle_new_user is gone. Then register a new
test user and confirm a matching profiles row is still created. The trigger must
keep working.
IP-31 · Low · Deleting a client silently detaches its invoices
What we saw
demo-app/supabase/migrations/20240902101500_init_schema.sql:29
client_id bigint references public.clients (id) on delete set null,
Removing a client sets client_id to NULL on all of their invoices rather than
preventing the deletion. The invoices survive but no longer record who they were
issued to.
The interface offers this as a one-click action with only a confirm() prompt
(demo-app/src/pages/Clients.tsx:46-54), and gives no warning that historical
invoices will be affected. Afterwards those invoices display “—” in the client
column (demo-app/src/pages/Invoices.tsx:159) and the public payment page shows
an empty “Billed to” field (demo-app/src/pages/PayInvoice.tsx:65).
What it costs you if it triggers
Invoices that no longer say who they were for are incomplete accounting records. The customer’s identity is a required element of a compliant invoice, and it cannot be reconstructed once the client row is gone, because the information is not stored anywhere else.
The realistic scenario is mundane. Somebody tidies up an old client and unknowingly strips the counterparty from years of historical invoices. Nothing warns them, nothing records what was lost, and the damage is only noticed when one of those invoices is next needed.
How to fix it
Soft-delete clients — the deleted_at column added in IP-15 — instead of removing
rows, and change the constraint to on delete restrict so a client with invoices
cannot be deleted outright. Denormalise the client name and address onto the
invoice at issue time as well, which is good practice regardless: an invoice
should record the billing details as they were when it was issued, not as they are
now. That change belongs with the issuer snapshot in IP-26 and is covered by its
prompt.
After it is done, check: create a client, issue an invoice to them, then attempt to delete the client. The delete must be refused, and the invoice must still name them.
IP-32 · Low · The password policy is six characters, enforced only in the browser
What we saw
demo-app/src/pages/Signup.tsx:65 sets minLength={6} on the password input.
That is an HTML attribute. It is enforced by the browser and by nothing else. Any
request that does not come from that form ignores it entirely.
There is no check for password strength, no rejection of common passwords and no check against known-breached password lists. Six characters is below current guidance and well below what resists offline guessing.
The application also has no multi-factor authentication option and no visible rate limiting on sign-in, which combines with the account enumeration in IP-20.
What it costs you if it triggers
Accounts fall to guessing rather than to any defect in your code, and the person whose account falls has your customers’ invoices, contact details and billing addresses in it. Because IP-20 hands an attacker a list of addresses that definitely exist, the guessing is aimed rather than blind.
The cost lands on you rather than on the user whose password was weak. A takeover here is indistinguishable from a breach of your platform to everybody it affects, and “the customer chose a six-character password” is not an explanation that survives contact with their lawyer or yours.
How to fix it
This fix is a platform setting rather than code, so there is no prompt. Set the minimum password length in the Supabase Auth settings, where it is enforced server-side, rather than relying on the form. Raise it to at least eight characters and enable the breached-password check. Consider offering multi-factor authentication, and note that for an application holding financial records this becomes a common customer requirement quickly.
After it is done, check: attempt to register through the API rather than the form, with a five-character password, and confirm the platform refuses it.
IP-33 · Low · The whole of lodash is bundled to provide two functions, in a single uncached chunk
What we saw
demo-app/src/pages/Dashboard.tsx:3
import { groupBy, sumBy } from 'lodash';
This is a root import of the full library rather than a targeted one, and it is the only lodash usage in the codebase. Both functions are a few lines of native JavaScript.
The built bundle at demo-app/dist/assets/index-DMjTepJ_.js is 487 KB
uncompressed, delivered as a single chunk with no code splitting, so every visitor
to the public payment page downloads the admin panel, the settings screen and the
dashboard’s charting code before they can pay an invoice.
What it costs you if it triggers
The page that collects your money is the slowest page to load, for the people least invested in waiting for it. Your client did not choose your product and has no reason to be patient with it, and a payment page that stalls on a phone on a weak connection is a payment that arrives later or not at all.
The second cost is that this is the same edit as IP-29. Carrying a library you use twice means carrying its exposure as well as its weight, and the two problems are closed by deleting the same import.
How to fix it
Remove lodash entirely and replace the two calls with native reduce, which also
resolves the exposure in IP-29, making this the same edit for two purposes. Then
add route-level code splitting with React.lazy so the public payment page does
not carry the authenticated application with it.
The fix prompt in IP-29 covers the lodash removal.
After it is done, check: confirm the dashboard’s monthly chart and the outstanding/collected totals are unchanged, and compare the built bundle size before and after.
Dependency and supply-chain picture
The application declares 6 production dependencies and 6 development
dependencies. Resolved through the lockfile (lockfileVersion 3), that becomes
131 package entries. For an application of this size that is a modest and
unremarkable tree.
Every package resolves from the public npm registry. We checked the
resolved URL of all 131 entries; all 131 point at registry.npmjs.org. No
alternative registry, no git or tarball URL, no local path.
Two packages declare install scripts. Both are platform-native binaries with
install steps that are normal for their kind. We did not inspect the contents of
node_modules, so this is an observation from the lockfile rather than a review
of what those scripts do.
Two direct dependencies are frozen at exact stale versions while everything
else floats. axios at 0.21.1 and lodash at 4.17.15, both with no caret,
against a lockfile that has otherwise resolved to current releases. This is IP-29,
and it is a recognisable signature of AI-generated dependency lists: the model
writes the version that was current in its training data, and the exact pin then
prevents the ecosystem from ever fixing it. It is worth checking for this pattern
in any future dependency the AI adds.
On names that do not resolve to an established package. We read every entry in
the tree looking for a name that should not exist, since a name that does not
exist can be registered by somebody else and no advisory scanner asks that
question. Every direct dependency is a well-known package under its expected name.
Two transitive entries are worth naming because they would not be obvious from
package.json:
| Package | Version | Arrives via | Note |
|---|---|---|---|
iceberg-js |
0.8.1 | @supabase/storage-js 2.112.0, as a direct dependency |
Declared upstream by Supabase’s own package. Unexpected in an invoicing app, and it arrived through a caret range rather than a deliberate choice |
@napi-rs/lzma-linux-x64-gnu |
1.5.1 | rollup 4.62.4, as an optional platform dependency |
Development-only, and one of a set of platform binaries rollup declares |
Neither is imported by any first-party file in this application, but do not
read that as “neither ships”. iceberg-js is compiled into the bundle every
visitor downloads: demo-app/dist/assets/index-DMjTepJ_.js.map lists
../../node_modules/iceberg-js/dist/index.mjs among its sources, and the
minified bundle carries the library’s own identifiers. It arrives because
@supabase/storage-js loads it unconditionally, and supabase-js loads that.
The rollup binary is development-only and does not ship.
We could not verify either package’s provenance or maintainership, because that needs a registry request. Both are named here so the check can be completed rather than assumed, and the one that executes in your users’ browsers is the one to complete first.
No automated dependency maintenance exists. There is no Dependabot or Renovate
configuration, no .github/ directory and no pipeline running an advisory check
(IP-28). Nothing would tell you when a new advisory lands against a package you
already use, which is how a currently-clean dependency becomes a finding six
months later without anybody changing a line of code.
What this section deliberately does not contain. No advisory counts, no
severity tallies, no CVE list. Producing those requires querying a vulnerability
database or a package registry, and this audit made no network request of any
kind. The lockfile is the input to that check and it is sitting ready; running
npm audit against it is the first item on the first day of the plan below.
What we could not see
This audit read code, configuration and build output. That is a real but bounded view, and the boundary matters: the items below are not assessed, and nothing in this report should be read as assurance about them. Several are questions only you or your platform accounts can answer, and we recommend working through them.
Checks this method would normally perform but did not, on this engagement
- Published advisory data for the dependency tree. No vulnerability database
and no package registry was contacted, because this audit makes no network
requests. IP-29 names two dependencies frozen at 2019 and 2021 versions and
deliberately stops short of saying what is published against them. Run
npm auditagainstdemo-app/package-lock.jsonas the first action after reading this; it needs no access we did not have, only a network connection. - The provenance of two transitive packages,
iceberg-jsand@napi-rs/lzma-linux-x64-gnu, named in the section above. Both are declared upstream by packages you chose deliberately. Confirming their maintainership and download history is a registry lookup. - The contents of
node_modules. We listed the installed directory names to confirm what the lockfile claims and stopped there. The analysis above derives frompackage.jsonandpackage-lock.json.
Platform settings we had no access to
- Supabase project configuration — whether email confirmation is required (which determines how exploitable IP-11 is), token lifetimes, password policy (IP-32), auth rate limiting, and whether Storage buckets or Edge Functions exist with their own exposure. We read the migrations; we could not read the dashboard.
- Whether RLS was changed outside the migrations. IP-02 rests on the
migration files. If somebody re-enabled row security in the dashboard
afterwards, the live state would differ. This is worth confirming directly, and
it cuts both ways: dashboard changes that are not in migrations will be
silently reverted by the next
db push. - Backups and point-in-time recovery. We found no evidence either way. We could not verify that backups exist, that their retention is adequate, or — the part that actually matters — that a restore has ever been performed. Given IP-15 and IP-26, this is the single most valuable item on this list.
- Vercel project configuration — the environment variables as actually set in production, who on the team can read them, the deployment history, and whether a rollback path exists. There are no release tags in the repository and no rollback procedure documented anywhere in it, so we have no evidence either way about how you would undo a bad deploy.
- Stripe account configuration — live versus test mode, which webhook endpoints are registered, and whether any fraud rules are active.
- Email domain authentication — the SPF, DKIM and DMARC records for the sending domain. These bound the blast radius of IP-07: a strict DMARC policy limits how convincingly your domain can be impersonated elsewhere, though it does not help against mail genuinely sent through your own endpoint.
- Account security for the platforms themselves — who has access to Supabase, Vercel and Stripe, and whether multi-factor authentication is enforced on those accounts. A perfectly secured application is undone by one unprotected platform login.
Things this method does not examine
- The running application. Nothing was executed, exploited or probed. No dev server, no build, no test run, no package script. Every finding above is derived from reading code, configuration and build artifacts. We describe what the code will do, not what we observed it doing.
- Behaviour under load. IP-24 identifies a lost-update race by reading the code; we did not reproduce it, and connection limits and performance are outside this scope.
- The history of the application itself. We had the git history of the
repository that currently contains
demo-app/, and used it: it shows the project arriving in a single commit, with no.envever committed anywhere in that history (IP-19). What it cannot show is anything about how the application was developed before that import, so we cannot assess review practice, and we cannot rule out that a secret was committed in some earlier repository. - Log contents and retention — where the data noted in IP-13 ends up and how long it stays there.
- Whether the deployed production build matches the
dist/we examined. Our build-output findings describe the artifact we were given, which was built on 4 August 2026. - Privacy and legal posture — privacy policy, data-processing agreements, records of processing, retention schedules. We note GDPR consequences where they follow from a technical finding, but this is not a compliance opinion or legal advice.
- Business context — which flows are critical, what downtime costs, what the recovery expectations are, and what the owner can and cannot explain about the system. Establishing that requires testing the system and talking to the business, which is the scope of a Production Readiness Review rather than this audit.
The first 30 days
This is the order we would fix in, and the reasoning behind the order. The sequence matters: several fixes are ineffective unless something else is done first, and one item is urgent for a reason unrelated to how hard it is.
First 72 hours — rotate, because the keys are already public
Three of these are one-line changes; the first is not a code change at all. Nothing else on this list matters while published credentials remain valid.
| Finding | Why now | |
|---|---|---|
| 1 | IP-01 — rotate the Supabase service-role key | It is in a file every visitor downloads. Treat it as compromised. Rotate before touching any code. |
| 2 | IP-03 — rotate the Stripe secret key, the Stripe webhook secret and the Resend key | Readable from the database by anyone, and sent to browsers. Rotating the webhook secret is also a prerequisite for the fix in IP-06 to mean anything. |
| 3 | IP-13 — remove the password from the log line | One line, at src/pages/Login.tsx:31. There is no reason to carry this for another hour. |
| 4 | IP-16 — set sourcemap: false |
One line. It stops publishing the source that makes everything else easy to find. |
| 5 | IP-29 — run npm audit |
Not a fix. It is the check this audit could not run, and its output decides where IP-29 belongs in the weeks below. Do it now because it costs a minute. |
Week 1 — close the doors that stand open to anyone
| Finding | Why here | |
|---|---|---|
| 6 | IP-02 — enable RLS and revoke the blanket grants | The root cause. Fixing it closes or narrows IP-05, IP-08, IP-09, IP-12, IP-14 and IP-20 at once. Do it first, and expect to spend the time writing policies. |
| 7 | IP-01 — remove supabaseAdmin from browser code |
Rotation stops the bleeding; this stops it recurring. Without it, the new key is published as soon as it is deployed. |
| 8 | IP-06 — verify the Stripe webhook signature | Direct financial loss, exploitable by anyone, independent of everything else. |
| 9 | IP-07 — authenticate the email endpoint | An open relay on your own domain. Deliverability damage is slow to detect and slow to undo. |
| 10 | IP-03 — remove the secrets from the database and the browser | Complete the rotation with the structural fix. |
Week 2 — make identity and ownership real
These share one theme: the application currently decides who you are and what you own in the browser. All of them depend on IP-02 being done first.
| Finding | |
|---|---|
| 11 | IP-05 — stop reading identity from localStorage |
| 12 | IP-08 — owner-check every invoice read and write, and take settlement off the browser |
| 13 | IP-14 — restrict which profile columns a user may write, and make the Pro upgrade take payment |
| 14 | IP-10 — move admin data behind a server-side permission check |
| 15 | IP-15 — ownership-check deletions, and soft-delete financial records |
| 16 | IP-04 — unguessable payment links, and stop exposing contact details |
| 17 | IP-11 — remove the email-suffix admin rule |
Weeks 3 and 4 — the money path, the inputs, and the safety net
| Finding | |
|---|---|
| 18 | IP-09 — escape invoice notes before rendering |
| 19 | IP-12 — validate the payment URL, and resolve the missing Stripe Checkout implementation |
| 20 | IP-21 — webhook idempotency, amount reconciliation, dead-lettering, and stop storing the whole payload |
| 21 | IP-24 — make the money-path writes transactional |
| 22 | IP-29 — act on whatever the advisory check returned |
| 23 | IP-17, IP-18, IP-19, IP-20, IP-23 — error detail, CORS and security headers, the project’s own .gitignore, account enumeration, input validation |
| 24 | IP-28 — linting, tests, the api/ type-check and a CI pipeline, including the automated bundle-secret check |
| 25 | IP-27 — error tracking and an alert on the payment path |
Beyond 30 days
IP-25 (a stored total, one implementation) and IP-26 (stop cascading financial records away) both need data migrations and some care, and are better done deliberately than quickly. IP-22 (per-issuer invoice numbering) is the same. IP-30, IP-31, IP-32 and IP-33 are housekeeping — fold them into whatever work touches those files next.
One thing to schedule rather than fix: confirm that database backups exist, that their retention is adequate, and that a restore actually works. It appears on no line above because it is not a code change, and it is the difference between IP-15 being an incident and being a catastrophe.
A note on how to apply the prompts
The paste-ready prompts in this report are written to be used one at a time, each followed by its check line. Applying several at once is how fixes get silently dropped: AI tooling will report success on a batch while having addressed part of it. Fix, check, commit, move on.
Treat the check line as the deliverable, not the prompt. Several of these findings — IP-01 above all — will appear fixed to a casual inspection while remaining fully exploitable.
What this audit is not
Stating the boundaries plainly is part of the deliverable, because knowing what was not assessed is how you judge what remains unmeasured.
- This is not a verdict. This report contains no ready/not-ready call, no launch recommendation and no readiness score, and their absence is deliberate. A number or a judgement that answers “can I launch?” requires testing the running system and understanding the business that depends on it: what the critical flows are, what an outage costs, what recovery looks like. That is the scope of a Production Readiness Review. This audit reads code and configuration and reports findings; it does not tell you whether to go live.
- This is not a penetration test. Nothing was exploited, no load was generated, no data was extracted, and no defence was tested by attempting to defeat it. Where this report says something is reachable, that is an assessment from reading the code, not a demonstration.
- No credential found in this repository was used. Not to check whether it still works, not once, not read-only. The service-role token was decoded locally, on this machine, to read its own payload; it was never sent anywhere. Every credential is listed by location at the end of this report and should be treated as compromised.
- This is not a security certification or a compliance audit. It is not ISO 27001, SOC 2, PCI DSS or a GDPR compliance assessment. Where GDPR consequences are mentioned, they are the plain business implication of a technical finding, not a legal opinion.
- This is not legal advice, including the observations about invoice numbering and record retention in IP-22, IP-26 and IP-31. Confirm those with a qualified accountant or lawyer in your jurisdiction.
- This is not exhaustive. No audit is. This one is bounded by what is visible in code, configuration and build output within a fixed scope. The limits are named in What we could not see, and the absence of a finding in any area is not evidence that the area is sound.
- No fixes were performed. Access was read-only throughout, and nothing in the application or its repository was modified. Findings arrive with instructions and prompts so they can be fixed with the same tooling that built the application. Having the findings fixed for you is a separate, scoped engagement, deliberately not bundled into an audit, because the product here is the judgement.
Confidentiality
Our standard commitments for this work:
- Access is read-only, and used for nothing except producing the report.
- Code and credentials are deleted after delivery.
- Anonymised finding statistics are retained only with written permission, given in the authorisation form rather than assumed in fine print. Nothing identifying an application or its owner is ever published.
- An NDA is available on request.
- No trackers on our pages, and no sponsored anything.
For this particular report, none of that is engaged. InvoicePilot is a demonstration application built in-house for training purposes, containing deliberately planted defects and placeholder credentials that are valid nowhere. It describes no client, no customer and no third party’s software, and it may be shared freely as a specimen of the audit format.
Credentials found
Listed by location, for rotation. None was tested. Every one should be treated
as compromised. The repository states at demo-app/README.md:6 that these are
placeholders valid nowhere; we report them anyway, because that is not a claim an
audit can verify from the outside and the rotation habit should not depend on it.
| Location | Credential |
|---|---|
demo-app/.env:2 |
Supabase anon JWT (public by design) |
demo-app/.env:3 |
Supabase service-role JWT |
demo-app/.env:5 |
Stripe publishable key, pk_live_ prefix |
demo-app/.env:6 |
Stripe secret key, sk_live_ prefix |
demo-app/.env:7 |
Stripe webhook signing secret, whsec_ prefix |
demo-app/.env:9 |
Resend API key, re_ prefix |
demo-app/supabase/migrations/20241004091200_settings_and_dashboard_fix.sql:11 |
The Stripe secret key again, as a SQL literal |
.../20241004091200_settings_and_dashboard_fix.sql:12 |
The Stripe webhook secret again, as a SQL literal |
.../20241004091200_settings_and_dashboard_fix.sql:13 |
The Resend API key again, as a SQL literal |
demo-app/dist/assets/index-DMjTepJ_.js:95 |
Both Supabase JWTs, compiled into the published bundle. The service-role claim was confirmed by decoding the token’s payload locally |
Questions about this report
A 15-minute debrief call is available on request, at no cost, to walk through anything here that would be easier discussed than read. It is offered, never required — this report is written to stand on its own.
Prepared by Vibe2Prod · 9 August 2026
Findings: 33 · 7 Critical · 8 High · 14 Medium · 4 Low
This report is an audit of a deliberately-vulnerable demonstration application built in-house for training and demonstration. It is not a client engagement.