Endpoints That Trust the Browser: Authorization Mistakes AI Makes
If a client hands you an application built with an AI coding tool, there is one class of defect worth checking before any other: missing server side authorization checks. Not authentication. The login screen is almost always there, and it usually works. The problem sits one layer down, where individual endpoints decide whether the logged-in user is allowed to do the specific thing they just asked for. In the AI-built apps we review, that decision frequently lives in the user interface and nowhere else.
The pattern repeats often enough across tools and stacks that it is worth treating as a default assumption until a review proves otherwise.
The Pattern: The UI Hides the Button, the Endpoint Still Answers
The shape is consistent. The frontend checks the user’s role and renders accordingly. An admin sees the Delete User button; a regular member does not. A member sees their own invoices; the component that lists everyone’s invoices never mounts. From inside the browser, the permission model looks complete.
Then you look at the API. The DELETE /api/users/:id endpoint checks for a valid session token and nothing else. Any authenticated user who sends that request directly gets the deletion. The UI-only permissions were a presentation choice, not a security control, because the browser is not a trusted environment. Anyone can open developer tools, copy a request, change it, and replay it. The interface hiding a button has no bearing on what the server will accept.
You already know this. The reason it is worth restating is that AI-generated codebases produce the gap at a rate that can catch experienced reviewers off guard. In hand-built applications, missing authorization tends to show up on a few overlooked endpoints. In AI-built applications, it can be the default condition across the whole API surface. Broken access control is not an exotic finding; OWASP has ranked it the top web application risk category since 2021. AI-generated code concentrates it.
Why AI Tools Produce Authentication Without Authorization
The asymmetry has a plain explanation. Authentication is a solved, self-contained pattern. Training data is full of login flows, session middleware, and JWT handling, and the prompt almost always asks for it directly (“add user accounts”). The tool produces a competent version because the task is generic.
Authorization is the opposite. It is specific to the application’s domain: who owns this record, which roles exist, what a manager can touch that a member cannot. That policy is rarely stated in the prompt, because the person prompting is describing features, not threat models. “Only admins can delete users” is in their head, expressed as which buttons each role should see. The tool satisfies the visible requirement, renders the UI conditionally, and moves on. No one asked for the server-side rule, so it was never written.
There is a second mechanism worth knowing. AI tools build endpoint by endpoint, each one a fresh completion. A human developer who writes an ownership check on one route tends to carry the habit to the next ten. A generation tool has no such habit; it re-decides every time. The result is inconsistent coverage even when some checks exist, which is arguably worse than none, because a spot check of two or three endpoints can return a false pass.
What Exploitation Looks Like: Changing an ID in a Request
No tooling beyond a browser is required. The classic case is insecure direct object reference. A member views their own invoice at GET /api/invoices/1042, opens the network tab, and resends the request as /api/invoices/1043. If the endpoint checks the session but not ownership, they are now reading another customer’s invoice. Iterate the ID and they can read all of them.
The write operations follow the same shape and do more damage. A PATCH to another user’s profile. A POST to an admin-only endpoint the attacker learned about by reading the frontend bundle, where every route is listed in plain text. A role field included in a profile-update payload that the server happily persists, because the endpoint accepts whatever the browser sends and the generated model never marked role as protected.
Two properties make this class of exposure unusually dangerous for a client business. It is discoverable by curious users, not just attackers, since changing a number in a URL is within anyone’s reach. And it often leaves no obvious trace: the requests are authenticated and well formed, so nothing in a default log distinguishes them from normal traffic. If the exposed records contain personal data, the client may also have a notification obligation, which turns a code defect into a legal event.
Server-Side Checks: Where They Belong and What They Cost to Add
The fix is conceptually simple: every endpoint that reads or mutates data enforces its own rule, on the server, regardless of what the UI implied. In practice the work splits into three layers.
Route-level role checks come first and cost the least. Middleware that gates admin routes by role is usually a few lines per framework, and most stacks have an established pattern for it. For a typical AI-built app with a modest API surface, this usually takes a few hours.
Object-level ownership checks are the bulk of the effort. Each endpoint that touches a record needs to confirm the requesting user owns it or has a role that spans it. The check itself is a query predicate (WHERE user_id = :currentUser or an explicit lookup), but it has to be applied per endpoint, and each one needs a human to decide what the rule actually is. This is the part the AI could not do, and it is the part that still requires judgment. Expect the policy conversation with the client to take as long as the code.
Field-level protections close the remainder: mass-assignment guards so a payload cannot set role or account_id, and response shaping so an endpoint does not return columns the caller has no business seeing. Generated CRUD code tends to serialize whole rows in both directions, so this pass usually finds something.
As a rough calibration from our reviews: retrofitting authorization onto a small AI-built app is typically days of focused work once the policy is decided. It is not a rebuild. The expensive version is the one that starts after an incident.
Verifying Coverage: How to Test That Every Endpoint Enforces Its Rules
Spot checks are not enough here, because coverage is inconsistent by construction. The method that works is an endpoint inventory tested against a permission matrix.
Enumerate every route first, from the router definitions, not from the documentation or the UI. Then build the matrix: each endpoint crossed with each role, plus one row for “authenticated user operating on a record they do not own.” Every cell gets an expected result, allow or deny.
Then test the deny cells directly against the API with real tokens: a member token against each admin route, user A’s token against user B’s record IDs, and unauthenticated requests against everything. Any 200 in a deny cell is a finding. The whole exercise can run with curl or a Postman collection, and once it exists it converts into automated tests that keep the matrix enforced as the codebase changes, which matters for AI-built apps that will keep being extended the same way they were started.
The checks worth automating permanently: unauthenticated access to protected routes, cross-user object access on every ID-bearing endpoint, role escalation through mass assignment, and admin routes under non-admin tokens.
None of this is beyond an MSP’s capability. It is mostly a question of whether the hours and the application-security depth are where you want to spend your bench. When a client brings you an AI-built app, authorization coverage is the first thing worth verifying, and it is work we take on white-label when it is more than you want to own. Your client relationship stays yours, the boundaries are in writing, and the deliverable carries your name. Either way, remember that the login screen only checks who someone is. The server endpoint is what decides what they’re allowed to do, and that check has to live there.


