Browse the blog
AI-built apps · Checklist

A security checklist for AI-built apps, in the order an attacker works

Fourteen checks, ordered by what a stranger reaches first: keys in your shipped JavaScript, secrets in git history, routes that answer when logged out, a database the public key can read, and one account reading another's records. Run your builder's own scan first, then work down the list and stop at the first failure.

This is the longer list, for the week before real users arrive. If you have fifteen minutes rather than an evening, start with six security checks you can run in 15 minutes and come back. This page assumes a web app built with an AI app builder or a coding agent, often with a Supabase database behind it, and it is ordered by what somebody poking at your app finds first rather than by what is easiest to fix.

Work down it and stop at the first failure. A confirmed cross-account read is worth more of your attention than everything below it, and fixing things in parallel is how you end up half-finished on all of them. You will need a second test account by check six, so make one now.

0. Run your builder's own scan

If your builder has a security scan or audit, run it before anything else. Several are free, several fix most of what they find, and some only run when you start them or switch them on, so nothing may have run against anything you shipped. It overlaps the list below. Work through the list anyway, because an automated review can find a missing check but cannot know what each of your users is meant to see.

1. Are there live keys in your shipped JavaScript?

Why it is first: it needs no account at all. Anyone can open your site and read every file it loads, and a build tool inlines anything named VITE_ or NEXT_PUBLIC_ into that output, so a variable that felt like configuration in the editor is public in the bundle.

How to check: open your deployed app, open developer tools, and press Ctrl+Shift+F (Cmd+Shift+F on a Mac) to search every loaded file, not just the page. Look for sk-, sk_live, secret, api_key and service_role. Then grep your repository for NEXT_PUBLIC_, VITE_ and REACT_APP_ and read what each one holds, because source and bundle disagree more often than people expect.

A failure looks like: a match inside a .js file. A Supabase publishable key there is expected and fine. An OpenAI key, a Stripe secret key or a Supabase service role key is an emergency, and the first move is rotating it rather than editing the code. Which keys may ship to a browser has the full version.

2. Is a .env file in your git history?

Why it is this early: if the repository is public, or ever was, it needs no account either. Deleting the file does not remove it, because the commit that added it still holds it, and bots scan new public repositories faster than you can react.

How to check: run git log --all --full-history -- .env in your repository. Any output at all means those values were committed at some point, however long ago and whether or not the file is there now.

A failure looks like: any commit listed. Rotate the keys today, before you touch the history, because rotating is what removes the risk and rewriting history does nothing about a key somebody already copied. Secrets in git history covers the cleanup.

3. Does anything answer when you are logged out?

How to check: log out completely, then paste every admin, settings and dashboard URL you know into the address bar. Include the ones you normally reach by clicking rather than typing, because those are the ones that only got a check in the component. Then pick an API route that returns data and call it with no session: curl -i https://yourapp.com/api/<something-that-returns-data>.

A failure looks like: the page loading, or a 200 with a body where you should see a 401 or 403. Also worth catching: the page sending you to the login screen while the request behind it returns a full payload. Read the network tab, not the screen.

4. Is your framework patched, and is middleware your only lock?

Why it is still early: it needs no account, and the advisory that explains how is public. In Next.js, CVE-2025-29927 let anyone skip middleware entirely by sending an x-middleware-subrequest header, and on a generated project middleware is often where all the authorisation lives.

How to check: open package.json and read the pinned version. For that flaw you need 15.2.3 or later on the 15 line, 14.2.25 on 14, 13.5.9 on 13, or 12.3.5 on 12. Then open middleware.ts, read the matcher, and open two or three protected route handlers to see whether any of them check the session themselves.

A failure looks like: a version below those, including a caret range that has never been reinstalled, or a route handler that reads a record and returns it with no session check of its own. Middleware protects paths, not data. It can miss a path through a matcher that does not cover it, and it cannot see who a specific record belongs to.

5. Can the public key read your database directly?

Why: your Supabase project URL and publishable key are in your frontend on purpose, so anyone can send queries straight to your database. Row-level security is the only thing between that key and every row, and a table created with SQL, which is how AI tools usually create them, has it off until something turns it on.

How to check: Supabase dashboard, table list, read the RLS column. Then the part most checklists miss: switching row-level security on does not take back the privileges the table was created with. Supabase documents this, and it is a separate statement nobody runs: revoke all on table <name> from anon, authenticated;, then grant back only what the app uses.

A failure looks like: a table marked disabled, or a table marked enabled that still answers an anonymous query. The second is the one people are surprised by. How to test Supabase grants and policies has a test you can run as a signed-out visitor and as two users.

6. Can a second account read the first one's data?

Why it matters most: this is the flaw that shows up most often in generated apps, and it looks perfect in testing. You only ever test as yourself, and as yourself every permission is correct.

How to check: sign up twice, in two browsers or one plus a private window. As account A, note a URL with an id in it, something like /orders/1041. As account B, paste it. Then repeat with the API calls you can see in the network tab.

A failure looks like: account A's data rendering normally for account B. Not an error page, not a blank page, the actual data.

Add authorisation checks so a user can only access their own records. For every route and every database query, verify the signed-in user's id matches the owner of the record before returning it. Do this on the server, not by hiding things in the UI. Return 404 rather than 403 for records that are not theirs. List every route that needs this and fix them.

7. Does every server action check who called it?

Why: a server action, any function under "use server" in Next.js, is a public HTTP endpoint with a friendly function signature on top. It looks like a local function call, which is exactly why people forget anyone with the page can call it directly.

How to check: search the project for "use server" and read every function under it. Each one needs to work out who is calling before it touches data.

A failure looks like: an action that takes an id and updates it with no line that resolves the current user and compares. deletePost(id) with no owner check is a delete-anything endpoint.

8. Does your payment webhook check its signature?

Why: if you take payments, your payment provider POSTs to a webhook saying a payment succeeded, and your code upgrades the account. Anyone who finds that URL can POST the same thing, and your handler cannot tell your provider apart from a stranger with curl unless it verifies a signature.

How to check: open your webhook handler and read the first ten lines. The signature has to be verified against the raw request body, before parsing, and the handler should return 400 when it fails.

A failure looks like: a handler that parses the body and acts on it before any signature check. One detail catches people: most frameworks parse JSON bodies automatically, and verification needs the raw bytes, so a check run against the re-serialised object fails on valid requests and tempts you to remove it. Six security checks shows the broken and the fixed handler.

9. Do your handlers check their input?

How to check: pick a POST handler and send it a body with an extra field, a wrong type and a missing required key. Send a string where a number belongs.

A failure looks like: a 500 with a stack trace, or worse, a 200 that wrote the extra field straight into the record. Spreading a request body into an update is a normal generated pattern, and it lets a caller set columns you never exposed, such as a role or a plan.

10. Does your paywall exist on the server?

How to check: as a free account, open the network tab, find the request a paid feature makes, and replay it. Then delete a display:none in the elements panel and see whether premium UI appears with real data in it.

A failure looks like: the request answering. Hiding a feature is presentation and checking a plan is authorisation, and only one of those survives somebody opening developer tools. How paywalls get bypassed covers the fix.

Enforce subscription checks on the server for every premium endpoint. Do not render premium UI for free users at all rather than hiding it with CSS. Store subscription status in the database, never in local storage. Return 403 when a non-subscriber calls a premium route. Show me every premium feature and where its check is missing.

11. Does a script survive a form field?

How to check: find a field whose contents another person will see, such as a display name, a comment or a bio. Enter <script>alert(1)</script>, save it, and view it from the other account. In a React project, also search for dangerouslySetInnerHTML and read where each value comes from, because React escapes output everywhere except that one prop.

A failure looks like: an alert box, or dangerouslySetInnerHTML fed by anything a user typed. Either means anything that field can hold runs in your users' browsers, including code that reads their session.

Sanitise all user input that is stored and later displayed. Escape HTML on output, or use a sanitiser like DOMPurify for fields that must allow formatting. Validate on the server, not only in the browser. Show me every field that is stored and rendered back to a user.

12. Is anything rate limited?

How to check: try your own login with a wrong password twenty times in a row, quickly. Then submit your signup form ten times.

A failure looks like: nothing slowing down or blocking you. Without a limit an attacker can try tens of thousands of passwords an hour against your users, and with one the same attack takes years.

13. Do your errors say too much?

How to check: break things on purpose. Submit garbage to a form, request a URL that does not exist, and log in with an email address that has no account.

A failure looks like: a stack trace, a file path, a database error, or a login that tells "wrong password" apart from "no such user". The last one tells an attacker which addresses are worth attacking.

14. Do you know what you would do if it happened?

There is no prompt for this one. Write four lines in a note: how to take the site down, your hosting provider's security contact, where your backups are, and a draft of the email you would have to send. Four lines is not an incident response plan, and it is far better than nothing at two in the morning.

What this checklist does not cover

Fourteen checks find fourteen kinds of problem, and they are the common ones because they are the ones people know to look for. They do not touch your business logic, and that is where the expensive failures live: a discount code that applies twice, an order total calculated in the browser and trusted on the way back, a form that can be replayed, a file upload that writes outside the folder it was meant to.

Finding those means reading the code, and a repository has more surface than anyone gets through in an evening. That is the job Sentrint does: it reads the whole repository, tells you what an attacker would reach first in plain English, and gives you a fix prompt to paste back into your coding agent. It is a code scan and not a pentest, so it reads what you wrote rather than attacking your live site, and the first scan is free with no card.

Work through the fourteen either way. Numbers one, five and six are most of your risk, and you can finish them tonight.