Skip to content
Veristria
See the evidence
All posts

How to audit an app that was built with AI

Ten checks for an application you did not read line by line — what to run, what a pass looks like, and what each one does not prove.

Veristria4 min read
aichecklist

Ten checks. Each one runs against your deployed application rather than your repository, each takes a few minutes, and none of them requires buying anything. Work through them in order — they are arranged so the ones that expose data come first.

Replace example-app.test and EXAMPLE-PROJECT with your own throughout.

The checks

1. Fetch what you actually serve

Open your deployed site, view source, and note the JavaScript chunk URLs. Then fetch them and search for credential formats.

curl -s https://example-app.test/_next/static/chunks/*.js \
  | grep -oE '(eyJ[A-Za-z0-9_-]{20}|sk_live_[A-Za-z0-9]{8}|sk-proj-[A-Za-z0-9]{8}|AKIA[0-9A-Z]{8}|whsec_[A-Za-z0-9]{8})'
  • Pass: no hits, or only hits you can identify as publishable credentials.
  • Does not prove: that tomorrow's build is clean. Every deploy produces a new artifact.

2. Decode any JWT you found

A three-segment string starting eyJ is a JSON Web Token and its payload is base64, not encryption.

echo '<the token>' | cut -d. -f2 | base64 -d
  • Pass: the role claim reads anon or authenticated.
  • Fail immediately: service_role. Rotate at the provider before doing anything else, including before removing it from your code.

3. Check for a served source map

curl -sI https://example-app.test/_next/static/chunks/main-EXAMPLE.js.map | head -1
  • Pass: 404.
  • Does not prove: anything about other build outputs. Minification is not obfuscation, and a served map hands back your original code with comments.

4. Ask your database what an anonymous caller gets

If a browser-reachable database holds your data, make the request a stranger would make, using the public key from your own bundle.

curl -s "https://EXAMPLE-PROJECT.supabase.co/rest/v1/<table>?select=*&limit=5" \
  -H "apikey: ${ANON}" -H "Authorization: Bearer ${ANON}"
  • Pass: an empty array, for every table that is not deliberately public.
  • Does not prove: that the table has rows. Confirm it is not simply empty, or the pass is meaningless.

5. Confirm row-level security is on everywhere

select tablename, rowsecurity from pg_tables
where schemaname = 'public' and rowsecurity = false;
  • Pass: no rows returned.
  • Does not prove: that the policies are correct — only that something is being consulted.

6. Look for policies that permit everything

select tablename, policyname, roles, qual from pg_policies
where schemaname = 'public' and qual = 'true';
  • Pass: no rows, or only tables you intend to be public.
  • Does not prove: the absence of disguised tautologies. A condition on a column the client controls, or a subquery that always returns a row, is equally permissive and will not match a string comparison.

7. Try a cross-tenant read

Sign in as one real user, take their access token, and request a row belonging to another.

curl -s "https://EXAMPLE-PROJECT.supabase.co/rest/v1/<table>?select=*&id=eq.<row owned by someone else>" \
  -H "apikey: ${ANON}" -H "Authorization: Bearer ${USER_JWT}"
  • Pass: an empty array.
  • Does not prove: anything about writes. That is the next check.

8. Try a write you should not be allowed to make

Attempt to insert a row owned by a different user. A policy with a using clause and no with check clause governs reads while permitting this.

  • Pass: the insert is rejected.
  • Does not prove: that updates are covered. Test those separately if the table is sensitive.

9. Reconcile a sample of refunds

If money moves on behalf of other accounts, take your last fifty refunds and check each one against the transfer it should have reversed.

const charge = await stripe.charges.retrieve(id, { expand: ['transfer'] });
// transfer.amount_reversed === 0 while charge.amount_refunded > 0
  • Pass: reversals present, and proportional where the refund was partial.
  • Does not prove: that disputes are handled. A lost dispute has the same structure and a shorter fuse.

10. Enumerate the URLs you forgot about

Preview deployments, branch URLs and staging hosts are public, are built with the same environment as production, and are indexed more often than teams expect. Run checks 1 to 3 against them too.

  • Pass: the same results as production.
  • Does not prove: anything about environments you did not find. Listing them from your hosting provider is more reliable than guessing.

What none of this tells you

These ten checks cover the failure modes that are characteristic of fast-shipping, AI-assisted development: a credential that crossed a boundary, data reachable without authorisation, and money that moved one way. They are the ones that are cheap to check and expensive to miss.

They are not a security review. They say nothing about your authentication design, nothing about business-logic flaws, nothing about whether a user can escalate their own privileges through a legitimate feature, and nothing about whether your data model is appropriate for the obligations you have taken on. If you need those questions answered, you need a person, and this list is not a substitute for one.

They also expire. Every result above is a true statement about the artifact you tested, and your next deploy produces a different artifact. Running this once tells you about today, which is a great deal more than never running it — and it is also the reason the checks worth keeping are the ones something else performs on a schedule.

See the three products

Veristria builds verification infrastructure for teams shipping software faster than they can review it. See the three products.