The day the trust assumption expires
Most AI-built MVP security problems do not appear when you write the code. They appear the day you flip the app from private beta to public, because that flip silently withdraws an assumption the whole app was built on: that every caller is someone you know.
In private beta the user set is a handful of people you invited. They use the app the way you expect, from the origin you expect, at a volume you expect. None of that is enforced — it is just true. The moment you post the public URL, it stops being true, and nothing in the code changes to notice. The same endpoints, headers, and storage rules now face anonymous strangers, automated crawlers, and people poking at inputs on purpose.
So this is not a flat checklist of "add these five headers". It is a map of the specific assumptions that break when trust is withdrawn, what each looked like in private beta, and the check that catches it before launch. Five places the shift lands hardest:
Some of these are observable from outside the app. Some are only checkable by reading the code or testing with a logged-in account. The note separates the two as it goes, because that line is exactly where an outside scan ends and manual review begins.
- Auth defaults that quietly treated every user as a trusted insider.
- A CORS allowlist that has only ever seen your own origin.
- Rate limits that never mattered with a few beta testers.
- Storage buckets and row-level rules never tested by a hostile anonymous caller.
- Admin and debug routes that were "fine" because only you knew the URL.
What private beta quietly assumed
Each of these shifts maps to a known weakness class — broken access control, security misconfiguration, missing function-level authorization in OWASP terms — but the trigger is the same human shortcut: the app was correct for the people who were actually using it, and that set just changed.
Auth defaults. In beta, a missing authorization check rarely bites, because the only logged-in users are people who would not call another tenant's record on purpose. The classic gap is authentication without authorization: the app checks that you are signed in, but not that the row you requested is yours. With trusted testers, no one tries the other ID. After launch, a logged-in stranger can. This is the distinction worth holding onto — being logged in is not the same as being allowed.
CORS allowlist. A cross-origin config that reflects the request origin, or allows * together with credentials, behaves fine when every browser request comes from your own front end. It only becomes a way for an attacker-controlled page to read authenticated responses once real third-party origins start hitting the API. In beta there were no third-party origins, so the misconfiguration was invisible.
Rate limits. With five testers, no endpoint needs a limit. The login route, the magic-link request, the signup form, and any expensive or AI-backed call all run unbounded, and that is genuinely fine — until the URL is public and the same routes face credential stuffing, link-request floods, or someone scripting your most expensive endpoint.
Storage and row-level rules. A storage bucket set to public-read, or a database row-level rule that was never exercised by an untrusted caller, looks correct as long as the only callers are trusted. The bucket "works" because you only ever uploaded your own files. The row rule "works" because no one queried someone else's row.
Admin and debug routes. A /admin page, a /debug panel, or a seeded test login is protected by obscurity in beta — it is fine because only you know the path. Obscurity is not a check; a public app will get its routes enumerated by crawlers and curious users, and a path that grants access by being unknown stops being unknown.
Why AI-built MVPs land here so often
This pattern is common in AI-built apps for reasons that have nothing to do with the model writing bad code, and everything to do with how these apps get built and shipped.
Generation optimizes for the working path. Ask an AI tool to "let signed-in users edit their profile" and you reliably get a working profile editor. The ownership check — does this row belong to this user — is a second, unprompted requirement, and it is the kind of thing that is easy to leave out because the happy path looks complete without it.
The fast-deploy loop skips the trust-boundary review. AI coding workflows compress build-to-deploy to minutes. The step that usually catches these issues — a review where someone asks "what happens when an untrusted person calls this" — is the step most likely to be skipped, because the app already runs.
Permissive defaults get scaffolded in to make things work. A wildcard CORS header, a public storage bucket, a disabled rate limit, a header that exposes the framework — these are the settings that make local development and a private demo frictionless. They are often added precisely to get past an error, and they survive into production because nothing fails loudly when they do.
None of this means the code is insecure by nature. It means the defaults were tuned for a trusted audience, and the launch is the moment that audience assumption stops being safe.
What to check before you make it public
Run these against the deployed public URL, from a clean browser session with no account, plus a couple of checks that need a logged-in account. For each, the safe result and the result worth stopping for.
Open the app with no account, in a fresh incognito window. Walk the routes a stranger could reach. Safe: protected pages redirect to login or return an authorization error. Worth stopping for: app data, another user's content, or an internal dashboard renders without you signing in. This is observable from outside and is a launch blocker, not a hardening task.
Check the CORS headers on your API. In DevTools, open the Network tab, trigger an authenticated request, and read the response headers — or call your API endpoint directly with an Origin header set to a random domain. Safe: Access-Control-Allow-Origin is absent or pinned to your own origin. Worth stopping for: the header reflects whatever origin you sent, or returns * while the response also sets Access-Control-Allow-Credentials: true. That combination lets a hostile page read authenticated responses.
Test object ownership with two accounts. This one needs login, so it is manual. Sign in as user A, note the ID in a record's URL or an API call, then sign in as user B and request user A's ID. Safe: a 403 or "not found". Worth stopping for: user B sees user A's data. That is broken authorization, and it is the single most damaging gap on this list because it is invisible from an unauthenticated scan.
Probe your storage and your public endpoints anonymously. Take a file URL from inside the app and open it in the incognito window with no session. Take any endpoint that returns data and hit it with no auth header. Safe: private files and private endpoints require a signed request and return an error otherwise. Worth stopping for: the bucket lists or serves files directly, or the endpoint returns records to an anonymous caller.
Enumerate the obvious sensitive routes. Try /admin, /debug, /dashboard, /api/internal, and any seeded test account from a clean session. Safe: each demands real authorization. Worth stopping for: any of them loads, or a known demo login still works. Treat a reachable admin route as a launch blocker.
Hit your auth and expensive routes a few times in a row. You are not load testing — you are confirming a limit exists. Safe: repeated login attempts, magic-link requests, or calls to an AI-backed endpoint start getting throttled or rejected. Worth stopping for: every request succeeds at full speed with no ceiling. Missing limits are usually a hardening task, but for an unauthenticated, expensive, or abuse-prone route, treat it as a blocker.
How to fix or reduce it
The fixes split cleanly into what an outside check can confirm and what only code and account-level testing can.
Move authorization to the server, per object. For every record a user can read or write, enforce on the server that the record belongs to that user — at the database row level where you have it, and again in the API handler. Do not rely on the front end hiding the option. This fixes the ownership gap, but it must be verified with the two-account test above; no outside scan proves your authorization logic is correct.
Pin CORS to known origins and never pair credentials with a wildcard. Replace origin-reflection and * with an explicit allowlist of the origins your app actually uses. This stops cross-origin reads of authenticated responses. It does not fix a missing authorization check behind the endpoint — a correctly scoped CORS policy in front of a broken access rule is still broken.
Make private storage private by default and serve files through signed, expiring URLs. Set buckets to private, then grant per-file access through short-lived signed links. This closes anonymous file access. It does not retroactively protect files that were already public and may be cached or crawled — rotate anything that was sensitive and already exposed.
Put limits on auth, signup, link-request, and expensive routes. Add per-IP and per-account rate limits on the routes that get abused first. This reduces brute force, link-flooding, and cost-amplification. It is a control against abuse volume, not a replacement for correct auth — a rate-limited endpoint that returns other users' data is still a data exposure.
Remove or properly gate admin, debug, and seeded test access. Delete debug panels and seed logins before launch, and put real role checks on admin routes. This removes the obscurity-only protection. Confirm it by hitting the routes from a clean session — config alone does not prove the route is gated in production.
Where VibeCodeGuard fits, and what it does not prove
This private-to-public flip is exactly the transition a Launch Check is built for: it gives the first external-eye, zero-trust pass against the public URL, the way an anonymous stranger arrives. From a clean browser session it flags open or wildcard CORS, missing security headers, exposed build and deploy artifacts, secret-shaped strings in the client bundle, and publicly readable buckets or endpoints — the misconfigurations that the trust shift turns from harmless to live, with severity and fix guidance for each.
What it cannot do is sit inside your auth logic. Whether user B can read user A's record, whether a row-level rule is correct, and whether an admin route enforces the right role are authorization questions that need the two-account test and a manual code review. A clean external scan means the observable public surface looks right; it does not mean the access-control logic behind it is correct.
Next step: before you share the URL, open one incognito window and walk the five checks above from no account. Then run a focused Launch Check against the public URL to catch the misconfigurations a clean session reveals, and keep the authorization tests on your own manual list.
A Launch Check confirms the outside-facing surface, not the correctness of your authorization rules. A clean scan plus an exposed admin route or a broken ownership check is still a launch blocker. Treat the scan as the external half and the two-account auth test as the half only you can run.
> launch check
Scan the public surface before launch.
Get severity, evidence, and practical fix guidance for the checks VibeCodeGuard can run from the outside.