Hiding the admin button is not access control
The concrete risk: your app hides the admin panel from normal users by checking a flag in the browser, but the API behind that panel still answers for anyone who calls it directly. A client side admin check bypass is not an attack you have to mount. It is the default state of an app where the UI decides what to render and nothing on the server decides what to allow.
Here is the shape of it. The frontend reads something like an isAdmin value from the user object, local state, or localStorage, and uses it to show or hide the delete-user button, the billing page, the export-everything link. That works visually. A regular visitor sees a normal app. But the button was only ever a convenience. When it is clicked, it calls an endpoint such as POST /api/users/delete or GET /api/admin/export, and the question that matters is whether that endpoint checks the caller's role before doing the work.
If it does not, the boundary is cosmetic. Anyone can open DevTools, read the request the admin button would have sent, and replay it without the button ever being visible to them. This is an authorization failure (who is allowed to perform an action), not an authentication failure (who you are). The caller may be perfectly, correctly logged in as a normal user. The server just never asks whether a normal user is allowed to do the admin thing.
- Observable: an admin API path returns 200 with real content to a request that carries no admin role, or no session at all.
- Only possible until tested: that every admin action across the app is unprotected. One open route does not prove the rest are, and one protected route does not prove the rest are either.
Why AI-built apps land here
AI builders optimize for a screen that works. When you prompt for an admin dashboard, the generated code reliably produces the visible half: a conditional render driven by an isAdmin flag, a route guard in the router, a menu item that disappears for non-admins. All of that is client-side, and all of it is real progress you can see.
The half that does not get generated as reliably is the server-side check on each privileged endpoint. The model has already made the feature appear to work, so the missing guard is invisible from the inside. You log in as the admin, the button shows, the action succeeds, the demo is done.
- The UI guard and the API guard are separate pieces of code, and only the UI guard is needed to make the screenshot look finished.
- Role data that lives in the client (a JWT claim read in the browser, a value in localStorage, a field on the user object) is trivially editable by the person holding the browser, so a frontend-only role check is advisory at best.
- Fast deploy means the public URL exists before anyone enumerates which API paths the admin features actually call. This is one of a family of generated insecure defaults; the broader launch surface is covered in the AI-built app launch security checklist.
Test that the server enforces the boundary
You can confirm this on your own app with no special tools and no attack on anyone else. The goal is to separate what the UI hides from what the API allows.
First, map the admin actions. Log in as an admin, open DevTools, go to the Network tab, and use each privileged control once: load the admin page, trigger an export, edit another user. Write down the exact requests, method and path, that fire. These are your admin endpoints.
Then test those same endpoints as someone who should not reach them. Open a second, clean browser session that is either logged out or logged in as an ordinary non-admin user. Replay each admin request from that session, for example by re-issuing the request from the Network tab or with a command-line client, using that session's own cookie or token. You are checking your own app as a normal user would experience it, not impersonating anyone.
Check each privileged path on its own. Authorization is per-route and often per-action, so a protected delete endpoint tells you nothing about an unprotected export endpoint sitting next to it.
- Suspicious result: the admin endpoint returns 200 with real data or performs the action when called by a logged-out or non-admin session. The server is trusting the hidden button.
- Safer result: the endpoint returns 401 (not authenticated) or 403 (authenticated but not authorized), and no data or side effect comes back. The server is enforcing the role itself.
- Try the frontend-only bypass directly: in the non-admin session, flip the client flag the UI reads (set isAdmin to true in the user object or in localStorage, or unhide the button). If the admin controls now appear and their requests succeed, the gate was never on the server. If the controls appear but every request still returns 403, the UI was misleading but the boundary held.
Move the check to the server, on every action
The fix is to make the server, not the browser, the source of truth for who may do what. The UI guard stays as a convenience so users do not see buttons they cannot use, but it stops being the thing that protects the data.
On every privileged endpoint, verify the caller's identity from a trusted source (a verified session or a signed token validated server-side), then check that the caller's role permits the specific action, before doing any work. The role must come from the server's own record of the user, not from a value the client sent. Return 403 when an authenticated user lacks the role, and do it as the first step in the handler, so no privileged code runs for an unauthorized caller. Centralize this in middleware or a per-route guard so a new admin route cannot ship without a deliberate decision about who may call it.
What this fix does not cover: it stops the wrong role from calling an action, but it does not validate what an allowed caller submits, and it does not by itself prevent one tenant or user from reading another's records through ID manipulation. That object-level check (can this specific user touch this specific record) is a separate control. It also does not fix data layers that bypass your API entirely, such as a database client reachable from the browser; that is governed by its own row-level rules.
- Authenticate first, then authorize: confirm who the caller is, then confirm the action is allowed for that caller's server-side role.
- Never trust client-supplied role data, including a role claim you have not verified server-side or any value read from localStorage.
- Apply the check on the action endpoints, not only the page or layout route, because the data flows through the API, not the rendered HTML.
Where VibeCodeGuard fits, and what it cannot prove
VibeCodeGuard's Launch Check works from the public surface, so it can probe common admin API paths and report a route that returns 200 with content to an unauthenticated request. When an admin endpoint answers an anonymous caller with real data, that is a strong, directly observable signal that the boundary is missing, and the scan flags it with severity and fix direction. For the wider pattern of admin paths reachable from outside, see exposed admin routes in AI-built apps, and for the general case of endpoints answering without a session, see unauthenticated API endpoints in AI-built apps.
What the scan cannot do is confirm that every privileged action enforces the right role for the right user. A scan probing from outside without an admin session cannot see whether a logged-in non-admin can still trigger an admin action, whether the role check is correct on each of your endpoints, or whether your server reads the role from a trusted source. That is authorization logic, and verifying it across every action means reading the code or running the logged-in tests above. A weak public-surface signal is a starting point, not a clearance.
Next step: list every admin action in your app, capture the request each one makes, and replay them from a clean non-admin session before you share the URL. Then run a focused Launch Check against the public URL so an admin route that answers anonymous callers shows up in your scan rather than in someone else's.
A clean Launch Check does not prove your admin boundary is enforced. The scan sees only what an anonymous outside caller reaches; it does not log in as a non-admin and try each privileged action, and it does not read your role-check code. Treat a flagged admin route as a launch blocker, and treat a clean result as one signal among several.
> launch check
Scan the public surface before launch.
Get severity, evidence, and practical fix guidance for the checks VibeCodeGuard can run from the outside.