// Technical

12 min read · September 22, 2026

How to review an AI-built SaaS app before customers rely on it

Prompts for having Claude Code or another agent review your AI-built SaaS app before customers rely on it, the evidence to demand from it, and the checks you still do yourself.

Updated

The app works. You built it over a few intense weeks with Claude Code or Cursor or Lovable, and people use it. Now it's about to matter more: a launch, a customer with sensitive data, maybe a bigger company whose security team sends a questionnaire. Somewhere around row 30 of that questionnaire, or the first time a customer asks, you realize you don't actually know whether one customer can see another customer's files.

That gap is common with AI-built and vibe-coded apps right now, and you need to close it before customers rely on the app. Getting to working software fast was still the right call. But "it works" and "it holds up when someone pokes at it" are different properties, and AI coding tools are much better at the first one. Fred Brooks estimated that turning a working program into a hardened, tested, documented product costs about nine times as much as writing the program. AI made writing the program nearly free. It did much less for the hardening, testing and documentation, and those are a big part of the work.

The same tools can do most of the review. Here's how I'd have Claude Code (or Codex, or whatever you use) review a small AI-built SaaS app before a real customer puts real data in it, with a prompt for each area and the parts you still check yourself. The running example is a fictional team dashboard with organizations, file uploads, an AI summary feature, and Stripe billing. If your stack looks different, the free review planner generates checks and prompts for it.

All of this is meant for you to run yourself. If you'd like a more in-depth review, we can do one with you.

If you only run two of these, run tenant isolation and secrets. That's where most of the bad days come from.

Set up the reviewer

An agent will happily review your code. Whether the review is worth anything depends almost entirely on the setup.

Start a fresh session. The session that built a feature shares its assumptions, and models like to agree with whoever they're talking to. Clear the context, or better, review with a different model or tool than the one that wrote the code. You want a skeptic here.

Run it against a copy, not production. Run the app locally or against a dev database, with two test organizations: a normal user and an admin in A, a user in B. Give the agent their tokens as environment variables. Several of these checks write and delete data on purpose, and a webhook replay can email a real customer twice, so keep them away from production, and keep production credentials away from the agent entirely. In July 2025, Replit's agent deleted Jason Lemkin's production database during a code freeze. If you don't have a dev database, build one from your migrations and fill it with fake data (supabase start does this locally). A straight copy of production is quicker, but it puts real customer data in front of the agent. Our sandboxing guide covers keeping credentials out of its reach.

One area per session. "Review this app for security issues" gets you a tour of the obvious. A focused prompt about tenant isolation finds far more. Subagents make it easy to run several areas in parallel.

Set rules for evidence. Put something like this at the top of every review session, or in the CLAUDE.md or AGENTS.md on a review branch:

prompt
You are reviewing this codebase before real customers rely on it.
You did not write it. Assume nothing works until you have seen it work.

Rules:
- Every finding needs evidence: file and line, plus a request and
  response or a failing test if you can run one.
- Label each result: observed in code, reproduced, or unverified.
- Report, don't fix. We fix after triage.
- End with what you did not check, and why.
- Never print a secret. Show its first four characters and where it lives.

Make it argue with itself. Agents over-report. Take each finding to a fresh session and ask for the opposite:

prompt
Another reviewer reported the finding below. Try to disprove it. Look
for a check it missed, a reason the code path can't be reached, or a
request to the running app that shows it's safe. Answer confirmed,
disproved or unsure, with evidence either way.

What survives is worth your time.

Keep the tests. A cross-tenant test that runs in CI on every pull request is worth more than any report, because it stays true.

Claude Code also has a built-in /security-review command, and Anthropic publishes a GitHub Action that runs the same review on every pull request. Both look at diffs rather than the whole codebase. They're good at keeping an app clean after a pass like this one, and not much use for the first pass.

Have it draw the map

Every later prompt builds on this one, so it's the output worth checking most carefully.

prompt
List every HTTP route, background job and webhook in this app. For
each, give: who can call it, where identity and tenant are checked
(file:line), and what data or services it touches. Output a markdown
table. Where you can't find a tenant check, write ??? instead of guessing.

You'll get something like:

RouteCallerTenant checkTouches
GET /api/reports/:idmemberquery filters on session.orgIdPostgres
POST /api/uploadsmemberobject key prefixed with org IDS3
POST /api/stripe/webhookStripesignaturePostgres, email
POST /api/summarizemember???OpenAI, Postgres

Every question mark is a test you owe yourself. Read the code behind those rows, and behind a few of the confident ones too. The table will be mostly right, and the rows it gets wrong are the interesting ones.

1. Authentication and sessions

prompt
Using the route table, find where the server establishes identity. For
every private route, send requests with no session, an expired session,
and the session of a user who has logged out. Report any route that
returns data. Check whether logout revokes anything on the server, and
whether password reset tokens are single use, expire quickly, and avoid
revealing which emails have accounts.

Two things to check yourself. If sessions are JWTs with a long expiry and no revocation, "logout" just deletes a cookie and the token keeps working. And look at where the auth check lives: if it's only in middleware, you have one lock on the front door and none on the rooms. In March 2025, Next.js fixed CVE-2025-29927, where a single request header made the framework skip middleware entirely. Apps that also checked the session in each route handler were fine. Apps that relied on middleware alone were wide open.

2. Tenant isolation and permissions

If I had to bet on where the bug is, it's here. Broken object level authorization sits at the top of the OWASP API Security Top 10 because it's easy to write and invisible in a demo. With one tenant, every query is correct.

prompt
For every route in the table that takes an object ID, write an
integration test that signs in as ALICE (org A, $ALICE_TOKEN) and
requests objects that belong to BOB (org B, $BOB_TOKEN) on $APP_URL.
Cover reads, writes, deletes, exports, file downloads, signed URLs and
bulk endpoints. Then repeat as a member-level user in org A against
admin-only actions. Any 200 or changed record is a finding. Run the
tests against the local app and report which ones fail.

Underneath, each of those tests is this request:

bash
curl -s -o /dev/null -w '%{http_code}\n' \
  -H "Authorization: Bearer $ALICE_TOKEN" \
  http://localhost:3000/api/reports/$BOB_REPORT_ID

You want a 404. A 403 is acceptable but confirms the record exists. A 200 means you've found the problem, and the cause usually looks perfectly reasonable in code review:

ts
// Passes every test when there's only one tenant
const report = await db.query.reports.findFirst({
  where: eq(reports.id, params.id),
});

// Scoped: the org comes from the session, never from the request
const report = await db.query.reports.findFirst({
  where: and(eq(reports.id, params.id), eq(reports.orgId, session.orgId)),
});

Read at least one of the tests it writes. Agents like to mock the exact layer you're trying to test, and a cross-tenant test against a mocked database proves nothing.

If the app talks to Supabase or Postgres straight from the browser, the database's row level security is your authorization layer, and the public key in your bundle is only safe if those policies are right. In May 2025, researchers scanned 1,645 apps built with Lovable and found 170 whose tables were readable with that public key alone (CVE-2025-48757). Have the agent run these and explain every policy in one sentence: who can read, who can write.

sql
-- Tables in the exposed schema with row level security off
select c.relname
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
  and c.relkind = 'r'
  and not c.relrowsecurity;

-- The policies that do exist, so you can read what they allow
select tablename, policyname, cmd, qual, with_check
from pg_policies
where schemaname = 'public';

An empty first result is good. A policy whose qual is just true is row level security in name only, and a policy the agent can't explain in one sentence is one to read yourself. Supabase's RLS guide has good patterns for scoping policies to auth.uid() and team membership.

3. Secrets and integrations

prompt
Build the app and search the build output for secrets: key prefixes
like sk_live_, sk-proj-, sk-ant- and AKIA, and database URLs. Run
`gitleaks dir` on the build output and `gitleaks git --redact` on the
full history. Then list every environment variable the code reads,
whether it can reach the browser, and what the credential behind it is
allowed to do. Never show more than the first four characters of a value.

gitleaks knows far more key formats than a grep. Anything named NEXT_PUBLIC_* or VITE_* is meant for the browser and gets inlined into the bundle wherever it's used, so check what's wearing the prefix. Some keys are meant to be public: a Stripe publishable key or a Supabase anon key in the bundle is expected, and safe only if the server (or RLS) enforces everything. A Stripe secret key, a service role key, or a database URL in the bundle is an incident.

The agent can find a leaked key. It can't rotate one, and it shouldn't be able to. Deleting a secret in a later commit doesn't remove it from history, so anything that was ever pushed gets rotated by a person. Key scopes are yours too, since they live in provider dashboards: is the Stripe key a restricted key, and do dev and production share credentials? And never paste live secrets into a chat, with an agent or anyone else.

4. Data handling

prompt
Trace customer data from collection to everywhere it is stored or sent:
tables, object storage, logs, analytics, caches, vector stores and AI
providers. List everything "delete this customer" would have to remove,
and check whether the code removes all of it. Then check file uploads:
size limits, type checks based on content rather than extension, where
files are stored, and whether they are served from our own domain.

AI features make copies people forget about: embeddings, cached summaries, prompt logs, traces in your observability tool. Deleting a customer means finding every one of those. On uploads: if you serve user files from your own domain, an SVG or HTML file with a script in it runs as your site. Serve user files from a separate domain, or with Content-Disposition: attachment and X-Content-Type-Options: nosniff.

Some of this lives outside the code, so it's yours to check. Bucket permissions and provider data settings are in dashboards. Your model provider is a subprocessor, and enterprise buyers will ask which provider you use, what it retains, and whether it trains on their data. And the only real test of deletion is deleting a test customer and looking.

5. Dependencies and deployment

prompt
Run `npm audit --omit=dev` and `osv-scanner scan -r .` (for Go,
`govulncheck ./...`). For each advisory, work out whether the vulnerable
code is reachable from ours and show how you know. Check that every
direct dependency exists on the registry, is the package we meant rather
than a similar name, and is maintained. In .github/workflows, list any
third-party action that isn't pinned to a full commit SHA.

Reachability is most of the work, and agents are good at it. osv-scanner covers most ecosystems, and govulncheck does the reachability triage itself for Go.

Checking that each package exists is specific to AI-written code. A 2024 study of 16 code models found that 19.7% of the packages they suggested didn't exist, and attackers have noticed that a name a model keeps inventing is a name worth registering (people call this slopsquatting). The pinning check exists because in March 2025 someone repointed the tags of tj-actions/changed-files, used by roughly 23,000 repositories, to a commit that printed CI secrets into the build logs (CISA's alert). A tag can be moved. A SHA can't.

The one thing here the agent can't do for you is prove your backups work. "Backups enabled" is a setting. A restore is a fact. Restore last night's backup into a scratch database and count the rows in your three biggest tables. On a host with point-in-time branching, like Neon, that takes about as long as a coffee.

6. Billing, jobs, and AI features

prompt
Review the Stripe webhook handler. Does it verify the signature against
the raw request body? What happens when the same event arrives twice, or
out of order? Write a test that delivers one event twice and asserts its
side effects happen once. Then list every paid or AI-backed endpoint and
show where the server checks the caller's plan and usage limits.

Stripe says plainly in its webhook docs that events can be retried, duplicated, and delivered out of order. A handler that copes looks roughly like this:

ts
export async function POST(req: Request) {
  const body = await req.text(); // raw body; parsed JSON breaks the signature
  const event = stripe.webhooks.constructEvent(
    body,
    req.headers.get('stripe-signature')!,
    process.env.STRIPE_WEBHOOK_SECRET!,
  );

  // A unique constraint on event_id turns a redelivery into a no-op.
  const [fresh] = await db
    .insert(stripeEvents)
    .values({ eventId: event.id })
    .onConflictDoNothing()
    .returning();
  if (!fresh) return new Response(null, { status: 200 });

  // ...handle the event once
}

If you can, record the event in the same transaction as its effect. Otherwise a crash between the two leaves you believing you handled something you didn't. For a live check, run the app locally in Stripe test mode with stripe listen forwarding to your webhook, replay an event with stripe events resend, and confirm the customer still has one subscription, not two. And if a free account can call the AI endpoint directly, your paywall is a CSS class. Put a per-tenant usage cap in the app and a hard spend limit at the provider, because a retry loop at 3am doesn't care about your pricing page.

The AI summary feature gets its own prompt:

prompt
For each feature that sends user content to a model: what can the model
read, which tools can it call, and whose permissions do those tools run
with? Could instructions hidden in an uploaded document make it do
something the uploading user couldn't do directly? Is model output ever
rendered as HTML, or as markdown that loads images?

Every uploaded document is a set of instructions your model might follow. OWASP calls this indirect prompt injection. If the model can only return text to the user who asked, the damage is limited. If it can call tools, send email, fetch URLs, or query across tenants, then so can the document. Give the model the requesting user's permissions and nothing more, and treat its output as untrusted: rendered markdown with an injected image link can make the browser send data to someone else's server. Simon Willison keeps a long list of real cases.

7. Logging and response

prompt
Find every place the app writes logs. Flag any line that could include
tokens, passwords, full request bodies, prompts or personal data. Then
list the security-relevant actions (login, permission change, export,
deletion) and say which are logged with user ID, tenant ID and request ID.

Enterprise buyers will ask for an audit trail of exports and permission changes. If you're already recording those events, the audit log is mostly a UI.

The last check is one no agent does for you: a fifteen-minute drill. It's 4pm on a Friday and your Stripe secret key is in a public gist. Who rotates it, where is it configured, what breaks when you do, and who tells customers? Time it. The first run always takes longer than you'd guess, which is why you do it before it's real.

What the agent can't tell you

An agent review covers more ground in an afternoon than a person could in a week. It will still miss things: a chain of small issues that only matters in combination, a setting that differs between your dev setup and production, an assumption it shares with the code because the code was written by something a lot like it. It also can't tell you which findings matter to your business. When you write up the results, say how you know each one: seen in the code, reproduced against the running app, or not confirmed yet. Anyone reading the report will want to know which is which, and a buyer's security team will ask.

If you want the long version of this list, OWASP ASVS Level 1 is the standard; think of this post as the part I'd run first. For the features enterprise buyers ask for beyond security, such as SSO, roles, and audit logs, EnterpriseReady is a good map. If a security questionnaire arrives, a short document covering scope, what you tested, what you found and fixed, and what's still open reads a lot better than a column of "Yes".

This is close to how I run security reviews for clients: frontier models cover the breadth, and I verify every finding before it goes in a report. If the app holds sensitive data for several tenants, or a buyer's security team is about to go through it, that second set of eyes is worth paying for. If the gaps are more about tests, deploys and monitoring, that's production readiness work.

Curtis Myzie

// written by

Curtis Myzie

Founder of Deep Noodle with 20 years of engineering and leadership experience as CTO, VP of Engineering, and Principal Engineer.