
Why the Backend Must Recheck Everything
A lot of web apps still make the same mistake.
The frontend decides what the user is allowed to do, and the backend quietly accepts whatever comes in.
That works right up until someone opens DevTools, replays a request, changes one field, and realizes the server never checked whether the action should be allowed in the first place.
This is where a lot of real bugs start.
The UI may hide a button. React state may disable a form. A menu item may only render for admins. None of that matters if the backend treats the request as trusted just because it came from the app.
The browser is part of the attack surface. If a user can send the request, they can modify it.
The frontend is not a security boundary
I still see this assumption everywhere:
- "The button is hidden for normal users"
- "The form only appears for paid accounts"
- "The client never sends that value unless the user has permission"
- "We already check this in React"
That is UI logic, not access control.
A browser is a programmable client sitting in front of your application. Anyone can:
- edit JavaScript state in memory
- modify requests in DevTools
- replay API calls with different values
- skip frontend flows entirely
- call private-looking endpoints directly
Once you accept that, the rule becomes simple:
every sensitive action has to be revalidated on the backend.
Not just once during login. Not just in middleware that checks whether the user is authenticated. Every action that changes data, reads restricted data, or triggers a privileged workflow needs its own server-side checks.
A simple example
Say you have a course platform.
Free users can preview lessons. Paid users can complete the full course and download a certificate.
The frontend does this:
const canDownloadCertificate = user.plan === "pro" && course.completed === true;
return (
<>
{canDownloadCertificate && (
<button onClick={downloadCertificate}>Download certificate</button>
)}
</>
);
This looks fine in the UI.
But what matters is what the backend does when /api/certificate/download gets called.
If the server only checks that the user is logged in, this becomes a security bug immediately.
A normal user can just send the request directly.
How this usually breaks in practice
I usually start with the Network tab.
The UI tells you what the product intends. The requests tell you what the backend actually trusts.
You click a restricted feature, watch the request, and then reduce it to the smallest test case.
A lot of the time, the request shape already tells you where the backend might be making assumptions:
{
"userId": "8472",
"courseId": "pro-course-12",
"completed": true
}
That payload should already make you suspicious.
Why is the client telling the server whether the course is completed?
Why is the server trusting userId from the request body instead of deriving it from the session?
Why is the client controlling a value that should be computed from backend records?
This is where the bug becomes real.
If the server trusts fields like these, a normal user can often change them and get behavior that should never be available.
A safer way to think about requests
The client should tell the backend what it wants to do, not why it is allowed to do it.
Bad pattern:
{
"userId": "8472",
"isAdmin": true,
"canExport": true,
"targetAccountId": "1002"
}
Better pattern:
{
"targetAccountId": "1002"
}
Then the backend decides:
- who the current user actually is
- what role they really have
- whether they can access that account
- whether the action is allowed for that account state
- whether the request should be denied or logged
The mistake here is pushing trust into the browser.
Once that happens, the application starts treating attacker-controlled input as policy input.
A practical test workflow
When I test a JavaScript-heavy app, I am usually looking for places where the frontend appears to enforce access control by itself.
Common signals:
- buttons hidden only in the UI
- feature flags exposed in frontend code
- role names stored in local state
- requests that include
isAdmin,role,plan, orcanAccess - API responses containing objects the current user should not even know exist
- "disabled" actions that still fire valid requests underneath
A quick browser hook helps show what the client is sending:
const originalFetch = window.fetch;
window.fetch = async (...args) => {
const [resource, config] = args;
try {
console.log("fetch request", {
url: typeof resource === "string" ? resource : resource.url,
method: config?.method || "GET",
body: config?.body || null,
});
} catch (error) {
console.log("log error", error);
}
return originalFetch(...args);
};This does not prove a bug by itself, but it helps you map how the frontend talks to the server.
From there, I usually ask:
- Can I replay this request without using the UI?
- Can I change identifiers like
accountId,projectId, oruserId? - Can I flip state values the backend should calculate for itself?
- Can I perform the same action from a lower-privileged account?
- Does the backend reject the action consistently, or only hide it in the UI?
If a request works only because the client sent a trusted-looking flag, the real fix belongs on the server, not in the component.
Common backend trust failures
These are some of the patterns I run into the most.
1. Trusting role flags from the client
The client sends something like:
{
"role": "admin"
}
or:
{
"isAdmin": true
}
That should never be enough to authorize anything.
Roles should come from backend-owned identity and authorization data, not from the request body.
2. Trusting object identifiers without ownership checks
The request sends:
{
"invoiceId": "inv_1002"
}
The backend fetches that invoice and returns it without checking whether the current user is allowed to see it.
This is where IDOR bugs live.
Authentication is not enough. The backend also needs object-level authorization.
3. Trusting client-side state transitions
The client sends:
{
"status": "approved"
}
But the server never verifies whether the current user has permission to approve anything.
This often shows up in admin tools, billing flows, moderation queues, and workflow systems.
4. Trusting hidden UI paths
A route is not linked anywhere in the UI for normal users, so the team assumes it is safe.
Then the endpoint still works if called directly.
This is common in older apps that evolved over time and kept internal endpoints around without real authorization.
Authentication is not authorization
A logged-in user is not automatically allowed to do everything that their session can reach.
That sounds obvious, but a lot of broken apps stop checking after confirming the user has a valid cookie or token.
Here is the difference:
| Check | What it answers | Why it is not enough alone |
|---|---|---|
| Authentication | Who is the user? | Does not decide what they can access |
| Authorization | Can this user do this action on this object? | Must be checked per action |
| Validation | Is the request shape and data acceptable? | Does not replace permission checks |
A lot of bugs happen because the backend does authentication and input validation, but skips real authorization.
That leads to endpoints that are technically "protected" but still vulnerable.
The frontend can still help
This is not a post about hating frontend checks.
Frontend checks are useful for user experience.
They can:
- hide irrelevant controls
- prevent accidental actions
- make the UI cleaner
- guide the user through valid flows
That is fine.
The problem starts when the same checks are treated as enforcement.
The frontend should improve usability. The backend should enforce security.
Both can exist together, but they are not interchangeable.
A realistic vulnerable flow
Imagine an internal team management app.
Only workspace owners should be able to remove members.
The frontend renders the delete button like this:
const canRemoveMember = currentUser.role === "owner";
The API request looks like this:
{
"workspaceId": "ws_123",
"memberId": "user_88"
}
A normal member opens DevTools, copies the request, and replays it manually.
If the backend only checks:
- user is authenticated
- workspace exists
- member exists
then the request succeeds even though the UI hid the button.
This is not a frontend bug.
The bug is that the server never verified:
- whether the current user belongs to that workspace
- whether they have the
ownerrole there - whether they are allowed to remove that specific member
A good report should show that clearly:
- the frontend hid the action correctly
- the backend accepted it anyway
- impact comes from missing server-side authorization
How to fix it properly
The fix is rarely complicated, but it has to be applied in the right place.
The backend should derive trust from server-side state, not client-supplied claims.
A better server flow looks like this:
- Identify the current user from the session or token.
- Load the target object from trusted storage.
- Check whether the user has the right permission for that object.
- Reject the action if the check fails.
- Log sensitive denials and privileged actions where appropriate.
Safe pseudocode:
app.post("/api/workspaces/:workspaceId/members/:memberId/remove", async (req, res) => {
const currentUserId = req.session.userId;
const { workspaceId, memberId } = req.params;
const membership = await getWorkspaceMembership(currentUserId, workspaceId);
if (!membership || membership.role !== "owner") {
return res.status(403).json({ error: "forbidden" });
}
const targetMember = await getWorkspaceMembership(memberId, workspaceId);
if (!targetMember) {
return res.status(404).json({ error: "member not found" });
}
await removeWorkspaceMember(memberId, workspaceId);
return res.json({ ok: true });
});Notice what is missing:
- no trusted
rolefrom the client - no trusted
userIdfrom the request body - no assumption that hidden UI means protected action
That is the core idea.
Things worth checking during review
When reviewing an endpoint, I like to ask these questions:
- Does the server derive identity from trusted auth state?
- Does it verify object ownership or tenant membership?
- Does it validate role or permission server-side?
- Does it treat client flags as hints instead of truth?
- Does it handle indirect object references safely?
- Does it enforce checks consistently across read and write routes?
If one route checks ownership but the export, archive, or delete route does not, the bug is still there.
Consistency matters more than isolated fixes.
Why this matters in bug bounty reports
A lot of valid reports get weakened because they focus too much on the UI behavior.
The cleaner way to frame the issue is:
- the frontend attempted to hide or disable the action
- the backend failed to enforce the same restriction
- a lower-privileged user could still perform the action directly
- impact comes from missing server-side authorization
That makes the issue easier to understand and harder to dismiss as "just a frontend problem."
This is also why reproductions should include a direct request, not just screenshots of hidden buttons.
The hidden button is context.
The accepted unauthorized request is the bug.
If the server rejects the modified request with a proper 403, the frontend
control may still be weak UX, but it is not necessarily a security issue.
Final thought
The browser can help with flow, clarity, and convenience.
It cannot be trusted to enforce security.
Users control the client. Attackers control the client even more aggressively.
So whenever the action matters, the backend has to recheck everything:
- who is making the request
- what object they are touching
- what permission they really have
- whether the action is valid in the current state
If the server does not make that decision for itself, sooner or later someone else will make it for you.


