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)
- 01Before you start
- 02Pass 1 - Secrets, in git and in the bundle
- 03Pass 2 - Identity and sessions
- 04Pass 3 - Authorization at the data layer
- 05Pass 4 - The server trust boundary
- 06Pass 5 - Input handling and injection
- 07Pass 6 - Uploads and object storage
- 08Pass 7 - Dependencies and supply chain
- 09Pass 8 - What is exposed to the internet
- 10Pass 9 - Rate limits and abuse
- 11Triage: turning findings into a plan
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
Freeze deploys for the window
A moving target makes every finding ambiguous. Pin the commit you are auditing and note the hash.
- 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
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
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.
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 .git log -p --all -S "SERVICE_ROLE" -- . | head -100
git log --all --diff-filter=A --name-only -- "*.env*" ".env*"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 | headAnything 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.
CriticalVerify: 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.
CriticalVerify: Compare the key's creation timestamp in the provider dashboard against the commit date.
No .env file has ever been committed, in any branch.
HighVerify: 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?
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.
HighVerify: Replay it against an authenticated endpoint and read the status code.
A reset link sent to one account cannot be used against another.
CriticalVerify: 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.
HighVerify: Run the flow with two mailboxes you control and read both.
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.
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.
# 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-control06
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.
# 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.
CriticalVerify: 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.
HighVerify: 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.
HighVerify: 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.
HighVerify: Check the host serving uploads. An HTML file served from your origin is stored cross-site scripting.
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.
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.
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.
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; echoThirty 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.
| Severity | Definition | Typical example | Response |
|---|---|---|---|
| Critical | One 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. |
| High | Exploitable 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. |
| Medium | Hardening whose absence needs a second failure to become an incident. | Missing security headers; unstructured logs; no absolute session lifetime. | Schedule within the month. |
| Low | Defence 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
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
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
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
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 CheckThe other three guides
Checklist · 9 min
Production readiness checklist
40 numbered checks across eight areas, each with a one-line verification step
Read the guideDecision guide · 9 min
Choosing your production stack
Five questions that decide the answer before you look at any provider
Read the guideModel · 9 min
Cost modelling at 10x and 100x
A four-step model you can build in a spreadsheet in an afternoon
Read the guide
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.