
What AI-Generated React Code Gets Wrong About Authorization
AI-generated React code is often decent at wiring screens together and bad at trust boundaries. The mistake is subtle: it can make a page look protected while the backend still accepts the request. That is not authorization. That is a visual guard.
Why AI-generated React code keeps authorization on the client
A lot of generated React code starts from the UI because that is where the prompt points it. “Hide the admin button” is easy. “Block access unless the user owns this resource” is harder, because the real rule lives in the API.
That gap shows up fast in reviews. The component checks user.role, the route wrapper checks isLoggedIn, and the data fetch still returns private records from an endpoint that never verifies ownership. The UI looks clean. The security model is still broken.
The common mistakes: hiding buttons, routes, and state instead of enforcing access
AI code tends to mix up three different things:
- rendering
- navigation
- authorization
Those are related, but not the same.
UI gating is not authorization
If a component hides a button for non-admin users, all it has done is remove one way to trigger an action. The action itself may still be callable from DevTools, a crafted fetch request, or another route.
function DeleteButton({ user }) {
if (user.role !== "admin") return null;
return <button>Delete</button>;
}
This is fine as UX. It is not a security control. If the backend accepts DELETE /api/users/123 from any authenticated user, the hidden button did nothing useful.
ProtectedRoute patterns that only protect the screen
A common AI pattern is a ProtectedRoute component that redirects unauthenticated users. That blocks the page, not the data.
function ProtectedRoute({ user, children }) {
if (!user) return <Navigate to="/login" replace />;
return children;
}
If the page loads sensitive data in useEffect, or if the API endpoint is open to any logged-in account, the route wrapper just changes the front door. It does not lock the room behind it.
What real authorization checks look like
Authorization belongs where the decision is made: the server.
Server-side role and ownership checks
A real check asks two questions:
- Is this user allowed to perform this action?
- Does this user own, manage, or have scope over this resource?
app.get("/api/invoices/:id", requireAuth, async (req, res) => {
const invoice = await db.invoice.findById(req.params.id);
if (!invoice) return res.sendStatus(404);
if (invoice.accountId !== req.user.accountId && req.user.role !== "admin") {
return res.sendStatus(403);
}
res.json(invoice);
});
That pattern matters because the server evaluates the rule on the actual resource, not on whatever the client happened to render.
Returning 403 vs 404 based on your threat model
The response code is part of the design.
- Use 403 when you want to confirm the resource exists but the user cannot access it.
- Use 404 when you want to avoid confirming that the resource exists at all.
Both are valid. Pick based on your threat model and how much enumeration risk you want to reduce. The important part is that the backend makes the decision.
Reproducing the bug in a small React example
Here is the kind of bug I keep seeing in generated code.
A flawed client-only guard
function AccountPage({ user, accountId }) {
const canEdit = user.role === "admin" || user.accountId === accountId;
return (
<div>
{canEdit && <button>Edit account</button>}
<AccountDetails accountId={accountId} />
</div>
);
}
That looks reasonable until you inspect AccountDetails.
function AccountDetails({ accountId }) {
const [data, setData] = useState(null);
useEffect(() => {
fetch(`/api/accounts/${accountId}`)
.then((r) => r.json())
.then(setData);
}, [accountId]);
if (!data) return null;
return <pre>{JSON.stringify(data, null, 2)}</pre>;
}
The UI only hid the button. The fetch still asks for the account by ID.
The backend request that still succeeds
If the API does not verify ownership, any authenticated user who can guess or enumerate an ID gets the data. In a real review, that is the part that matters. The button was cosmetic.
Safer patterns for React apps
Treat client checks as UX only
Use client-side checks to improve navigation and reduce noise. Do not use them as a control plane.
Centralize authorization in the API
Make the backend the source of truth for:
- role checks
- ownership checks
- team or org membership
- resource scope
- field-level access when needed
A good rule: if the data would hurt to leak, the API should decide before the client sees it.
Keep sensitive data out of initial client state
Avoid shipping private records into page props or global state “just in case the UI needs it.” If the browser receives the data, the user has it. Load only what the current request is allowed to see.
How to review AI-generated code before it ships
Questions to ask in code review
- What stops a user from calling the API directly?
- Where is ownership verified?
- Does the client only hide UI, or does the server enforce the rule?
- Can a lower-privileged account access the same endpoint with a different ID?
- Are we leaking data before the authorization check runs?
Tests that catch broken access control
Write tests against the API, not just the component.
- Call the endpoint as the owner and confirm success.
- Call it as another authenticated user and confirm denial.
- Try an invalid or missing resource and confirm the expected status code.
- Repeat with a user who has a different role but no ownership.
- Check that private fields are not returned in list endpoints by default.
A small integration test catches more than a lot of polished UI code.
Conclusion
AI-generated React code often gets the surface right and the trust boundary wrong. If the app only hides buttons, redirects screens, or filters state on the client, you do not have authorization. You have presentation.
The fix is boring but reliable: enforce access on the backend, keep the client honest about what it can display, and test the API as if the UI does not exist.


