Skip to content
Playbook

The AI-built app security audit guide

The order we work in when we audit an app that came out of Lovable, Replit, Base44 or Claude Code - with the commands and queries we actually run at each pass.

At a glance

Format
Playbook
Sections
11
Verifiable checks
14
Commands to run
16
Reading time
12 min
Last revised
20 August 2026

What you take away

  • Secrets first, then unauthenticated access, then authorization between users. That order matches how damage actually happens.
  • Every finding needs a reproduction, not a suspicion. If you cannot demonstrate it, you cannot verify the fix.
  • The API is the app. Testing through the interface tests the interface.
  • Rotating an exposed key is part of the fix, not an optional follow-up.

Written for: Technical founders and engineers who can run a shell command and a SQL query, auditing an app they own.

Contents (11 sections)

01

Before you start

This playbook is for auditing an application you own. Run it against staging with a production-shaped dataset, tell your team before you start, and take notes as you go - half the value is the written record.

  1. 1

    Freeze deploys for the window

    A moving target makes every finding ambiguous. Pin the commit you are auditing and note the hash.

  2. 2

    Create two ordinary test users

    Almost every authorization finding is demonstrated by user A reaching something belonging to user B. You cannot test that with one account.

  3. 3

    Get read access to the database

    Several passes are SQL. A read-only role against a restored snapshot is enough and is safer than production.

  4. 4

    Open a findings document

    One row per finding: what, where, how to reproduce, severity, owner.

02

Pass 1 - Secrets, in git and in the bundle

Start here because it is the cheapest pass with the largest blast radius. A leaked service-role key defeats every other control you are about to audit, so there is no point auditing them until you know.

Working tree - anything secret-shapedbash
grep -RInE "(service_role|SERVICE_ROLE|sk_live_|rk_live_|AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]*PRIVATE KEY)" \
  --exclude-dir=node_modules --exclude-dir=.git --exclude-dir=.next .
History - a deleted secret is still a leaked secretbash
git log -p --all -S "SERVICE_ROLE" -- . | head -100
git log --all --diff-filter=A --name-only -- "*.env*" ".env*"
The client bundle - the only file an attacker actually readsbash
npm ci && npm run build
# JWT-shaped strings that shipped to the browser
grep -RIno "eyJ[A-Za-z0-9_-]\{20,\}" .next/static dist build 2>/dev/null | cut -c1-160 | head
# and the source maps, if they are being served
ls -la .next/static/chunks/*.map 2>/dev/null | head

Anything prefixed for client exposure - NEXT_PUBLIC_, VITE_, REACT_APP_, EXPO_PUBLIC_ - is inlined into the bundle at build time. That is correct for a publishable anon key and catastrophic for a service-role key, and the two look almost identical in a config file. Decode any JWT you find and read the role claim.

  • No privileged key appears in the built client bundle or in any served source map.

    Critical

    Verify: Grep the build output, then decode each match and read its role claim.

  • Every key found in git history has been rotated after the date of the commit.

    Critical

    Verify: Compare the key's creation timestamp in the provider dashboard against the commit date.

  • No .env file has ever been committed, in any branch.

    High

    Verify: Search history by filename across all refs, not just the current branch.

03

Pass 2 - Identity and sessions

Builders wire up a working sign-in quickly. What tends to be missing is everything after sign-in: expiry, revocation, verification, and the recovery flows that are the usual way accounts get taken over.

  • Where does the session live? A token in localStorage is readable by any script on the page, including one injected through a dependency.
  • Does sign-out revoke the token server-side, or only remove it from the browser?
  • How long is a token valid, and is refresh bounded by an absolute lifetime rather than sliding forever?
  • Is email verification required before actions that cost money or expose data?
  • Are password-reset tokens single-use, short-lived, and tied to one account?
  • Is the OAuth redirect list an exact-match allowlist, with no wildcard host or open path suffix?
Replay a token after signing out - expect 401bash
curl -s -o /dev/null -w '%{http_code}\n' https://staging.example.com/api/me \
  -H "Authorization: Bearer $OLD_TOKEN"
  • A token captured before sign-out stops working after sign-out.

    High

    Verify: Replay it against an authenticated endpoint and read the status code.

  • A reset link sent to one account cannot be used against another.

    Critical

    Verify: Request a reset, then attempt the token against a second account id.

  • Changing an email address requires confirmation at the new address and notifies the old one.

    High

    Verify: Run the flow with two mailboxes you control and read both.

04

Pass 3 - Authorization at the data layer

This is the pass that finds the most, and the one worth the most time. In a Supabase-backed app the database is reachable directly from the browser, so a policy gap is not defence in depth - it is the whole door.

1. Which tables are unprotected?sql
select c.relname            as table_name,
       c.relrowsecurity     as rls_enabled,
       c.relforcerowsecurity as forced
from   pg_class c
join   pg_namespace n on n.oid = c.relnamespace
where  n.nspname = 'public' and c.relkind = 'r'
order  by c.relrowsecurity asc, c.relname;
2. What do the policies actually say?sql
select tablename, policyname, cmd, roles, qual, with_check
from   pg_policies
where  schemaname = 'public'
order  by tablename, cmd;
3. The pattern that is almost always the findingsql
select tablename, policyname, cmd, qual, with_check
from   pg_policies
where  schemaname = 'public'
  and  (qual in ('true', '(true)') or with_check in ('true', '(true)'))
  and  ('authenticated' = any(roles) or 'public' = any(roles) or 'anon' = any(roles));

A policy of the form “allow select where true, for the authenticated role” reads in review as “logged-in users can see their data”. It means every logged-in user can see every row. It is the single most common critical finding we file against generated Supabase projects, and it is generated because it makes the demo work.

Then stop reading and test it

Policy review finds the obvious cases. The empirical test finds the rest: sign in as user A and ask the API for data that belongs to user B. Do this through the REST endpoint, not through your app - your app is not the only client that can call it.

Ask for everything, as a normal logged-in userbash
API="https://<project>.supabase.co/rest/v1"

# Row count user A can actually read from a per-tenant table
curl -s "$API/orders?select=id,user_id&limit=1000" \
  -H "apikey: $SUPABASE_ANON_KEY" \
  -H "Authorization: Bearer $USER_A_JWT" | jq 'length, (map(.user_id) | unique | length)'

If the second number is greater than one, user A can read other tenants' rows. Repeat for insert, update and delete - read policies and write policies are separate, and a table frequently has a correct select policy next to an update policy nobody wrote.

The three bypasses people forget

Views, definer functions and public buckets sidestep table policiessql
-- Views run as their owner unless they are declared security_invoker
select table_name from information_schema.views where table_schema = 'public';

-- Functions marked security definer ignore the caller's policies
select p.proname, p.prosecdef
from   pg_proc p join pg_namespace n on n.oid = p.pronamespace
where  n.nspname = 'public' and p.prosecdef;

-- A public storage bucket is readable by anyone with the object path
select id, name, public from storage.buckets;
  • Row-level security is enabled on every table in the application schema.

    Critical

    Verify: The pg_class query returns no false in rls_enabled for application tables.

  • No policy resolves to a constant true for anon or authenticated on per-tenant data.

    Critical

    Verify: The pg_policies query returns zero rows.

  • User A cannot read, update or delete user B's rows through the REST API.

    Critical

    Verify: The distinct owner-id count in the curl probe is exactly one, for every table and every verb.

  • Every view and definer function has been reviewed for what it exposes.

    High

    Verify: Read the body of each one listed and confirm it filters by the caller.

05

Pass 4 - The server trust boundary

Every value that arrives from a client is an assertion, not a fact. The recurring pattern in generated code is a handler that takes an identifier from the request body and uses it as though the server chose it.

Handlers that trust the caller's claim about who they arebash
grep -RInE "(body|params|searchParams|query)[^\n]{0,40}(userId|user_id|accountId|org_id|tenant|role|isAdmin)" \
  --include="*.ts" --include="*.tsx" --include="*.js" src app 2>/dev/null | head -40
  • Identity comes from the verified session on every request. A user id in the body is input, not identity.
  • Object references are checked for ownership before use, not just for existence. Sequential integer ids make this trivial to exploit - try id − 1.
  • Update handlers name the fields they accept. Spreading a request body into an update lets a caller set role, plan, balance or verified.
  • Webhooks verify the provider's signature and are idempotent by event id. An unverified payment webhook is a free-orders endpoint.
  • CORS does not combine a wildcard origin with credentials, and the allowlist does not include a preview domain anyone can deploy to.
  • Errors returned to clients do not include stack traces, SQL fragments or internal hostnames.
Two quick probesbash
# Insecure direct object reference: ask for the neighbouring id as user A
curl -s -o /dev/null -w '%{http_code}\n' "https://staging.example.com/api/invoices/1042" \
  -H "Authorization: Bearer $USER_A_JWT"

# Permissive CORS on a credentialed endpoint
curl -sI -X OPTIONS "https://staging.example.com/api/orders" \
  -H "Origin: https://not-your-domain.example" \
  -H "Access-Control-Request-Method: GET" | grep -i access-control

06

Pass 5 - Input handling and injection

Modern query builders remove most classic SQL injection, and then a single hand-written string interpolation puts it back. AI-generated code writes both styles in the same file.

Interpolated SQL, raw HTML, and unvalidated boundariesbash
# String-built SQL
grep -RInE "(query|execute|raw|sql)\(\s*[\`"'].*(SELECT|INSERT|UPDATE|DELETE)" --include="*.ts" src | head -20

# HTML written from data
grep -RIn "dangerouslySetInnerHTML\|innerHTML" --include="*.tsx" --include="*.ts" src | head -20

# Request bodies that are used without a schema
grep -RIn "await req.json()" --include="*.ts" src | head -20
  • Every request body and query string is parsed by a schema at the boundary, and the parsed value - not the raw one - is what the rest of the handler uses.
  • User-supplied HTML is sanitised on the server with an allowlist, not escaped by hand in the template.
  • Features that fetch a URL on the user's behalf reject private address ranges, link-local addresses and redirects into them. Server-side request forgery is how cloud metadata credentials get stolen.
  • File paths derived from user input are resolved and confirmed to sit inside the intended directory.
  • Model prompts that mix system instructions with user text assume the user text is hostile. If the model can call tools or read other users' records, prompt injection is a privilege escalation path, not a content problem.
  • Redirect targets after login are validated against an allowlist rather than echoed from a query parameter.

07

Pass 6 - Uploads and object storage

Storage is where a demo shortcut turns into both a data leak and a bill. Public buckets with guessable keys are the default in most generated projects, because that is the configuration where images render on the first try.

  • Buckets holding user content are private, and access is granted through short-lived signed URLs.

    Critical

    Verify: Query storage.buckets for public = true, then request a known object path with no credentials.

  • Object keys are not guessable from a user id or a sequential number.

    High

    Verify: List a few keys. If you can construct another user's path by hand, so can anyone.

  • Uploads are limited by size, count and content type, and the type is checked server-side.

    High

    Verify: Upload a 200 MB file and a file whose extension disagrees with its contents. Both should be rejected.

  • User uploads are not served from the application's own origin.

    High

    Verify: Check the host serving uploads. An HTML file served from your origin is stored cross-site scripting.

Is the bucket actually public?bash
curl -s -o /dev/null -w '%{http_code}\n' \
  "https://<project>.supabase.co/storage/v1/object/public/avatars/$KNOWN_OBJECT"

08

Pass 7 - Dependencies and supply chain

A long agent session adds packages quickly, and some of them were installed to solve a problem that no longer exists. Each one runs with your application's full privileges.

Advisories, drift and the direct-dependency listbash
npm audit --omit=dev
npm outdated
npm ls --depth=0
  • The lockfile is committed and CI installs from it, so the build cannot drift between machines.
  • No dependency is pinned to a floating tag such as latest or next.
  • Every direct dependency has an owner who can say what it does. If nobody can, it should not ship.
  • Package names are checked against the ones you meant to install - typosquats differ by a hyphen or a single letter.
  • Install scripts from packages added during a build session have been reviewed, since they run at install time with your credentials in the environment.
  • High and critical advisories have either a fix applied or a written decision to accept them.

09

Pass 8 - What is exposed to the internet

Everything so far assumed traffic reaches the app through the front door. This pass checks how many other doors exist.

Headers, and files that should not be reachablebash
curl -sI https://app.example.com | \
  grep -iE 'server|x-powered-by|strict-transport-security|content-security-policy|x-frame-options|x-content-type-options'

for p in .env .env.local .env.production .git/config config.json backup.sql; do
  printf '%-20s -> ' "$p"
  curl -s -o /dev/null -w '%{http_code}\n' "https://app.example.com/$p"
done
  • The database is not reachable from the open internet. Check the provider's network allowlist for an entry covering every address.
  • No development server, debug mode or verbose error page is running in production. A dev-mode framework server is single-process and is documented as unsafe to expose.
  • Preview and branch deployments do not point at the production database, and are not indexed by search engines.
  • Admin panels, database consoles, queue dashboards and API documentation are behind authentication.
  • GraphQL introspection is disabled in production if the schema is not meant to be public.
  • Transport security is enforced with a redirect and a long-lived HSTS header, on the apex and on every subdomain in use.

10

Pass 9 - Rate limits and abuse

The last pass is about the endpoints where a script costs you money: sign-up, password reset, email and SMS sending, file upload, and anything that calls a model. On a staging environment you own, count how far you get.

Staging only - never point this at production or a third partybash
for i in $(seq 1 30); do
  curl -s -o /dev/null -w '%{http_code} ' -X POST https://staging.example.com/api/auth/login \
    -H 'content-type: application/json' \
    -d '{"email":"probe@example.com","password":"wrong"}'
done; echo

Thirty identical failures with no 429 among them means there is no rate limit. On authentication that is credential stuffing; on a model-backed endpoint it is an unbounded bill, and the invoice arrives before the alert does.

  • Authentication endpoints are rate limited per address and per account, with lockout or backoff.
  • Sign-up has a real abuse control - verified email, a challenge, or manual approval - before anything expensive runs.
  • Endpoints that send email or SMS are limited per user and per hour, with a global ceiling.
  • Model calls have a per-user quota and a hard monthly cap. Token counts are logged per request from day one.
  • Spend alerts exist on every metered provider, at a threshold you would want to be woken for.

11

Triage: turning findings into a plan

A list of thirty findings is not actionable. Severity is what makes it a plan, and the rubric has to be written down before you start rating, or everything urgent becomes critical.

SeverityDefinitionTypical exampleResponse
CriticalOne customer's data is reachable by another, by an unauthenticated request, or is unrecoverable.Permissive row-level security policy; service-role key in the bundle; no working backup.Stop other work. Fix and verify today.
HighExploitable with a precondition, or certain to break within months of real traffic.Unsigned webhook; no rate limit on password reset; missing ownership check on an update.Fix before launch, this week.
MediumHardening whose absence needs a second failure to become an incident.Missing security headers; unstructured logs; no absolute session lifetime.Schedule within the month.
LowDefence in depth, or a risk you can knowingly accept.Verbose server header; dependency advisory with no reachable code path.Backlog, with the decision written down.

Fix in this order

  1. 1

    Rotate every exposed credential

    Before any code changes. Until this is done, the rest of the work is protecting a system someone else may already hold the keys to.

  2. 2

    Close unauthenticated access to data

    Anything that returns real records without a session. This is usually a small number of routes and a policy or two.

  3. 3

    Fix authorization between users at the data layer

    Policies and ownership checks, with a test per table that asserts user A cannot reach user B.

  4. 4

    Then the hardening

    Rate limits, headers, validation schemas, dependency updates. Important, but none of it helps while the first three are open.

Get this audited for free

If you would rather not run this yourself, this is exactly what our audit does - with a written report at the end.

Book a Free CallProduction Check

Want a second pair of eyes on the results?

Send us what you found. The first call is free and you keep the report either way.

Book a Free Call