saas-preflight
A pre-ship security and payment audit for SaaS built fast with AI, on the Next.js + Supabase + Stripe stack. It finds the failure modes that AI-generated code ships by default: a stranger reading another user's data, a stranger getting a paid plan for free, a webhook that silently fails so subscriptions never activate, a middleware that fails open.
This skill is defensive only. It detects weaknesses in the user's own codebase so they can be fixed. It never writes exploit code, never produces an attack payload, and never targets a system the user does not own.
The 7 categories
Every finding carries a rule id of the form CATEGORY-SUBJECT-NNN, and every
rule belongs to one category. They are the spine of the audit. Work through all
of them; do not stop at the first scary thing.
AUTH: Is every protected route, server action, and data query actually checking who the caller is? Covers the service_role key, middleware that fails open,getSessionused for trust, CSRF on cookie-authenticated route handlers, and open redirects after auth.DATA: Can a user reach rows, files, or columns that are not theirs? Covers Supabase RLS coverage, queries with no owner filter, mass assignment, and Storage buckets.BILLING: Can money be charged without access granted, or access granted without money? Covers Stripe webhook signature verification, idempotency (event.iddedupe), and entitlement written from a client-supplied redirect.INPUT: Is untrusted input validated and bounded before it hits the database, the filesystem, an outbound fetch (SSRF), or the DOM?CONFIG: Do secrets, keys, and origins differ between environments in a way that breaks prod or leaks keys to the client?ABUSE: Can an anonymous user run up the bill (LLM calls, emails, storage) or exhaust quotas through races?TENANCY(multi-tenant only): Can one tenant reach another tenant's data or session? Covers session cookie scope across subdomains, tenant context spoofing, subdomain and domain creation validation, and domain deprovisioning on downgrade. Skipped automatically for single-tenant apps, which is most of them.
Degraded-mode behaviour (what happens when Stripe or Supabase is slow or down) has no rules of its own yet; it is checked by reading, under whichever category the failing call belongs to.
Confidence
Every finding carries a confidence level. It is derived from how the finding was detected, never estimated, so it can always be justified in one sentence:
CONFIRMED: a candidate was detected and a required guard is provably absent from the file, for example a Stripe webhook handler that never callsconstructEvent. This is evidence, though still not proof of exploitability.LIKELY: found in a qualifying context, but no absence was proven. Either no guard marker exists for the rule, or the guard could live in an imported module (a reserved-name deny-list, for instance). Read the code to settle it.NEEDS_REVIEW: a plain textual match. Context unverified. These are leads for reading, nothing more, and there are many of them by design.
There are no percentages, and you must not invent any. Nothing in this tool measures calibration, so a number would promise a precision that does not exist.
Workflow
Follow this order. The scanner is an optional accelerator, not a gate: if it cannot run on this machine, do the triage by reading the code yourself and continue. Never report a grep hit as a confirmed vulnerability without reading the actual code first.
1. Scope the repo
Find the project root and confirm the stack. Look for package.json (Next.js),
a supabase/ directory or @supabase/* imports, and stripe usage. Note
whether the app uses the App Router (app/) or Pages Router (pages/), and
whether there are server actions, route handlers, or both. If the stack is not
Next.js + Supabase + Stripe, say so plainly and adapt: the 7 categories still apply,
but the specific patterns in the reference files may not match. The scanner also
detects multi-tenant signals (a tenant or org table, Host or subdomain routing, a
customer-domains table); TENANCY is only in scope when at least
one is present, and is skipped otherwise.
2. Run the scanner (optional fast first pass)
The scanner gives candidate findings in seconds. It is a convenience, not a requirement. The full audit comes from reading the code against the 7 categories, so if it does not run on this machine, do not stop: go to step 3 and do the triage yourself by reading the code.
node scripts/cli.js <path-to-repo>
It needs Node and nothing else: the engine is vendored in this skill, so there
is no npm install and no network call. Any repo worth auditing here is a
Next.js app, so Node is already on the machine.
Useful flags:
node scripts/cli.js <repo> --format json # machine-readable, for CI or tooling
node scripts/cli.js <repo> --ci # exit non-zero on a CONFIRMED finding
With --ci: 0 nothing confirmed, 1 confirmed P2/P3, 2 confirmed P1,
3 confirmed P0, 4 usage error. Only CONFIRMED findings affect the exit
code, so a pipeline is never broken by an unverified grep hit.
If Node is unavailable, say so in one line and proceed without it. The audit is never blocked by a missing scanner.
Treat every finding as a lead to verify, including CONFIRMED ones: the engine
proves that a guard is missing from a file, not that an attacker can reach it.
On a multi-tenant app the TENANCY rules run too; on a single-tenant app they
are skipped and listed under "not applicable", so they create no noise.
If you skip the scanner, your step 3 reading must cover all 7 categories from scratch rather than starting from findings. Use the reference files as your checklist so nothing is missed.
3. Verify against the reference files
For each category with findings, and for every category regardless if the app is about to handle real money or real users, read the matching reference and verify by reading the actual code:
references/auth-and-isolation.md:AUTHandDATA. Server-side auth on routes and actions, middleware fail-open, Supabase RLS, object ownership (IDOR), mass assignment, CSRF on route handlers, open redirect after auth.references/payments.md:BILLING. Stripe webhook signature and idempotency, subscription state as source of truth, checkout and guest-checkout races, downgrades and refunds.references/abuse-validation-config.md:INPUT,CONFIG,ABUSE. Input validation, SSRF, rate limiting, quota races, unbounded cost, secrets and env, CORS, Supabase Storage, degraded-mode behavior.references/tenant-isolation.md:TENANCY, multi-tenant only. Read this only when the app is multi-tenant.
Read a reference only when you reach its category. This keeps context lean.
A finding is real only if you can point to the exact file and line and explain the concrete consequence ("an authenticated user can read row X belonging to another user because the query filters by nothing"). If you cannot, downgrade it to a note or drop it.
4. Write the report
Produce the report using assets/REPORT_TEMPLATE.md exactly. Prioritize by
severity. For every finding give: the rule id, the category, the confidence, the
file and line, what an attacker or unlucky user can do, and a concrete fix. Write
the fix as remediation, never as a working exploit.
Do not carry a NEEDS_REVIEW finding into the report as though it were real.
Either you read the code and confirmed it, in which case say so, or you drop it.
The scanner's job is to point your reading; the report is your verdict, not its.
Severity model
- P0: Ship blocker. Any authenticated or anonymous user can read or write data that is not theirs, or obtain paid access without paying, or cause money loss. Fix before shipping, full stop.
- P1: Fix this week. Exploitable but needs a specific condition (a known id, a race window, a misconfigured env). Real risk, slightly higher bar.
- P2: Hardening. Not directly exploitable today but one refactor away from P1, or missing defense in depth (no rate limit, no idempotency key yet).
- P3: Hygiene. Secrets in logs, dead config, weak CORS on a non-sensitive route, TODOs near auth.
For TENANCY: a cross-tenant data read or write, or a session shared across
tenants, is a P0 (a stranger reaching data that is not theirs). A missing domain
deprovisioning on downgrade or a missing reserved-name deny-list is typically P2.
Severity and confidence are independent axes. A rule's severity says how bad it
would be if real; its confidence says how sure the detection is. A P0 at
NEEDS_REVIEW is not a ship blocker until you have read the code and confirmed
it, which is exactly why the CI exit code ignores unconfirmed findings.
If you are unsure between two levels, state the assumption that decides it rather than guessing silently.
Output discipline
- Lead with the count by severity and the single most important thing to fix.
- No filler. Every finding earns its place.
- If a whole category is clean, say so in one line. Clean categories build trust.
- Never invent a finding to pad the report. If the repo is solid, say it is solid and stop.