Cursor accelerates coding but doesn't audit the code it generates. Here are the production gaps that appear most often in Cursor-built apps.
Default stack: varies · varies
Cursor produces code that reads like code a careful engineer wrote, which is the whole point and also the specific hazard. Reviewing generated code is nothing like reviewing a colleague's: there is no author whose habits you know, no commit message explaining the trade-off, and no moment where someone hesitated. Everything arrives equally confident, including the parts that are wrong.
The failures that survive review are the ones with no visible symptom. A webhook handler that parses a payload but never verifies its signature works perfectly in every test you run, because your tests send real payloads. An auth middleware applied to eleven of twelve routes passes every click-through, because you click the eleven.
The checks below target exactly that category: correct-looking code with a missing verification step. None of them are about style, and none will be caught by a linter.
The part that breaks first
Cursor writes confident, plausible code — including a Stripe webhook that parses the body but never verifies the signature. It looks done. It passes your manual test. And it lets anyone on the internet POST a fake 'payment succeeded' event straight into your database.
And then this becomes your life
So now you read every diff twice. You don't trust the auth middleware until you've clicked every route yourself. You keep a mental list of 'things the AI probably got subtly wrong' — and that list is the real product you're maintaining.
Cursor made you feel like a 10x developer. Then the app went live and you became a full-time code reviewer for an author who never sleeps, never explains itself, and confidently ships race conditions you only find when two users hit the same row.
The good news: every one of these is findable, and most are fixable fast. Here's exactly what tends to be wrong in Cursor apps.
These aren't hypothetical — they show up in the free audits. See the full production-readiness guide →
| # | Gap | Severity | Where to look |
|---|---|---|---|
| 1 | Stripe webhook without signature verification | Critical | Webhook route handler |
| 2 | Auth middleware applied inconsistently | Critical | Route registration / middleware config |
| 3 | Read-modify-write without a transaction | High | Database write paths |
| 4 | Payment calls without idempotency keys | High | Payment integration |
| 5 | Error responses that leak internals | Medium | Error handling middleware |
Stripe signs every webhook it sends, and the SDK's constructEvent both verifies that signature and parses the payload. A handler that calls JSON.parse on the body instead skips the verification step and cannot tell a real Stripe event from one anybody POSTed — including a checkout.session.completed that unlocks paid features for free.
How to check yours
Confirm the handler verifies before it trusts, and that it reads the raw body — verification fails against a body that a framework has already parsed and re-serialized. Then prove it: POST a hand-made event with no valid signature and confirm you get a 400 rather than a granted entitlement.
# Does the handler verify at all?
grep -rn 'constructEvent\|STRIPE_WEBHOOK_SECRET' app/ src/ pages/ 2>/dev/null
# Forge an event — a correct handler answers 400
curl -i -X POST https://your-app.example/api/stripe/webhook \
-H 'Content-Type: application/json' \
-d '{"type":"checkout.session.completed","data":{"object":{"id":"cs_fake"}}}'Generated code protects the route it was asked about. Over a few sessions that produces a codebase where most routes check the session and some do not, with no pattern to the exception. One unprotected route that returns user data is a breach regardless of how many protected ones sit next to it.
How to check yours
Enumerate every route the app serves, then request each one with no session and record the status. The list of routes that answer 200 is your actual public API. Do this from the deployed URL, and re-run it whenever routes are added — this is the check most worth automating.
# List every route the app serves
find app -name 'route.ts' -o -name 'route.js' | sed 's|/route\..*||'
# Hit each unauthenticated and record the status
for r in /api/users /api/orders /api/admin/stats; do
printf '%s -> ' "$r"
curl -s -o /dev/null -w '%{http_code}\n' "https://your-app.example$r"
doneReading a value, computing a new one in application code, and writing it back is correct exactly until two requests do it at once — then one of the two updates disappears. Credit balances, inventory counts, and usage meters are the usual casualties, and the bug is invisible until you have concurrent users.
How to check yours
Find the read-then-write pairs and check whether anything makes them atomic: a transaction, a database-side increment, or a conditional update on the previous value. Then reproduce it — fire the same request twice in parallel and compare the result against what two sequential requests would have produced.
# Two concurrent writes to the same row
curl -s -X POST https://your-app.example/api/credits/spend -d '{"amount":1}' &
curl -s -X POST https://your-app.example/api/credits/spend -d '{"amount":1}' &
wait
# Then read the balance: did it drop by 2, or by 1?Networks retry. A charge request that times out after the payment provider received it will be retried by your client library or by your user hitting the button again, and without an idempotency key the provider treats the retry as a second, separate charge.
How to check yours
Check that every mutating payment call passes an idempotency key derived from something stable — an order id, not a random value generated per attempt, which defeats the point. Then send the same request twice with the same key and confirm the provider returns the original result instead of creating a second charge.
grep -rn 'idempotencyKey\|Idempotency-Key' app/ src/ lib/ 2>/dev/nullA generated catch block that returns the error object gives the caller your stack trace, file paths, dependency versions, and sometimes the failing query with its parameters. Individually that is embarrassing; together it is a map of your application handed to whoever is probing it.
How to check yours
Trigger a failure on purpose — malformed input, a missing required field — and read the whole response body. Users should get a message and a correlation id. Stack traces and query text belong in your logs, not the response.
curl -s -X POST https://your-app.example/api/items \
-H 'Content-Type: application/json' \
-d '{"broken":' | head -c 800Work through these in order before you share the URL publicly. The items marked critical are the ones that expose data to people who never had an account.
Stripe webhook uses constructEvent with the webhook secret and the raw bodycritical
Every route enumerated and tested unauthenticated against the deployed URLcritical
Read-modify-write paths use transactions or database-side atomic updatescritical
Payment calls send a stable idempotency key derived from the ordercritical
Error responses return a message and correlation id, never a stack trace
Concurrency reproduced with parallel requests, not assumed
The questions that come up most often about Cursor apps in production.
Cursor writes plausible code, which is not the same thing. It reliably produces the feature you asked for and unreliably produces the verification steps around it — signature checks, transactions, idempotency keys. Those omissions have no visible symptom in testing, so they survive review unless you look for them specifically.
Confirm the handler calls stripe.webhooks.constructEvent with your webhook secret and the raw request body — verification fails if a framework parsed and re-serialized the body first. Then prove it by POSTing a hand-made event with no valid signature. A correct handler answers 400; anything else means unsigned events are being trusted.
Because read-modify-write is the natural way to express 'subtract one from the balance', and it is correct until two requests run at once. Generated code optimizes for the single-request path, so the transaction or atomic update that makes it safe under concurrency rarely appears. Reproduce it with two parallel requests before assuming you do not have it.
It is a value you send with a payment request so the provider can recognize a retry of the same operation rather than treating it as a new one. You need it because networks retry: a charge that times out after the provider received it gets sent again, and without the key that is a second charge. Derive it from the order id, not from a fresh random value per attempt.
Enumerate every route the app serves — for a Next.js App Router project, find every route.ts under app/ — then request each one with no session and record the status code. The routes that answer 200 are your real public API. It is a two-minute loop and the single check most worth wiring into CI, because new routes reintroduce the gap.
No. A stack trace tells the caller your file paths, dependency versions, and sometimes the failing query and its parameters. Return a human-readable message and a correlation id the user can quote to you, and keep the detail in your logs where you can still search it.
Get the free audit. Three quick fields, a written report personally reviewed by Shane Jordan — not a scanner. You'll know exactly what's wrong in your Cursor app and what to fix first.
Request Free AuditI take a limited number of audits at a time · priority review available