I see this mistake a lot in small web apps. The browser blocks something, so the backend is treated as protected.

I would not build on that mental model. Browser security mostly protects the user sitting in the browser. Your backend still has to protect itself from every client that can send HTTP.

What the browser actually does

The browser has real security rules.

Same-origin policy restricts how a script loaded from one origin can interact with resources from another origin.

CORS lets a server tell the browser which other origins should be allowed to read a response. For some cross-origin requests, the browser sends a preflight request first and checks whether the server allows the actual method and headers.

The browser enforces that rule.

Say your frontend at https://app.example.com runs a request like this.

await fetch("https://api.example.com/admin/users", {
  method: "DELETE",
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ userID: "123" }),
})

the browser may block the frontend from reading the response unless the API sends the right CORS headers.

That does not mean the backend rejected the operation.

The request may still reach your server. For non-browser clients, CORS is not the gate at all:

curl -i -X DELETE "https://api.example.com/admin/users" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer stolen-or-low-privilege-token" \
  --data '{"userID":"123"}'

curl, Postman, a script running on a VPS, and a compromised mobile app do not care that your React app would not show the button.

Frontend checks are for honest users

Frontend validation is useful. I want the form to catch a bad email before it hits the network. The upload picker should reject a 200 MB file immediately. Normal users should not see admin buttons.

But none of those checks should be the reason the backend accepts or rejects the request.

Keep the frontend check:

// frontend
if (file.size > 10 * 1024 * 1024) {
  throw new Error("File is too large")
}

This still has to exist:

// backend
const maxUploadBytes = 10 * 1024 * 1024

if (uploadedFile.size > maxUploadBytes) {
  return reply.status(413).send({ error: "File is too large" })
}

Same for plan limits:

// frontend
const canGenerate = currentUsage.generatedThisMonth < plan.monthlyGenerations

Enforce the same limit on the backend:

// backend
const usage = await getUsageForAccount(accountID)
const plan = await getPlanForAccount(accountID)

if (usage.generatedThisMonth >= plan.monthlyGenerations) {
  return reply.status(402).send({ error: "Generation limit reached" })
}

The frontend makes the happy path nicer. The backend decides what is allowed.

Check the backend with direct requests

When I review this class of bug, I start by copying requests out of DevTools and changing the values.

In Chrome or Safari:

  • submit the real form once
  • open the Network tab
  • copy the request as curl
  • remove or change fields
  • replay it from the terminal

Then I try the requests the UI is supposed to prevent:

# Can I update a server-owned field?
curl -i -X PATCH "https://api.example.com/me" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  --data '{"name":"Hwee-Boon","role":"admin","plan":"enterprise"}'

# Can I access another user's object?
curl -i "https://api.example.com/invoices/inv_other_user" \
  -H "Authorization: Bearer $TOKEN"

# Can I run an expensive operation after the UI disables it?
for i in {1..20}; do
  curl -s -X POST "https://api.example.com/generate" \
    -H "Content-Type: application/json" \
    -H "Authorization: Bearer $TOKEN" \
    --data '{"prompt":"write me a long report"}' >/dev/null
done

Use whatever shell you use. Just stop testing only through the UI.

I want to see boring failures:

  • 401 when the request is not authenticated
  • 403 when the user is authenticated but not allowed
  • 404 when the object should not be visible to that user
  • 413 when an upload is too large
  • 422 when the body has invalid client-controlled fields
  • 429 when the caller is over the rate limit

If changing a JSON field gives me admin access, a cheaper plan, another user’s data, or extra paid API calls, the backend is trusting the frontend.

CORS is not authorization

CORS is often the part that confuses people because it looks like an access control system.

For example:

Access-Control-Allow-Origin: https://app.example.com

That header tells browsers whether frontend code from that app origin may read the response. It does not prove the caller is your app. It does not prove the user is allowed to perform the action.

Do not write code like this:

const origin = request.headers.origin

if (origin !== "https://app.example.com") {
  return reply.status(403).send({ error: "Forbidden" })
}

await deleteProject(request.body.projectID)

That blocks some browser-based abuse. It is not enough.

Write the boring checks:

const user = await requireUser(request)
const project = await getProject(request.body.projectID)

if (!project || project.accountID !== user.accountID) {
  return reply.status(404).send({ error: "Not found" })
}

if (user.role !== "admin") {
  return reply.status(403).send({ error: "Forbidden" })
}

await deleteProject(project.id)

The exact framework does not matter. The rule does: authenticate the user, load the server-side record, check ownership and permissions, then do the work.

OWASP calls this Broken Object Level Authorization when APIs let callers access objects by changing IDs. It is still one of the easiest backend bugs to create because the happy path works perfectly.

Do not trust client-owned fields

The fastest way to create a backend bug is to accept the whole request body and pass it into your database update.

Avoid this:

await db.user.update({
  where: { id: user.id },
  data: request.body,
})

The UI may only send this:

{
  "displayName": "Hwee-Boon"
}

but an attacker can send this:

{
  "displayName": "Hwee-Boon",
  "role": "admin",
  "billingPlan": "enterprise",
  "emailVerified": true
}

Use an allowlist:

const body = updateProfileSchema.parse(request.body)

await db.user.update({
  where: { id: user.id },
  data: {
    displayName: body.displayName,
    avatarURL: body.avatarURL,
  },
})

This applies to reads too. Do not return a giant object and rely on the frontend to hide sensitive fields. Return the fields the current user is allowed to see.

OWASP’s Broken Object Property Level Authorization covers this kind of overexposure and mass assignment problem.

CSRF is the browser being too helpful

Cookies make this more subtle.

If your app uses cookie-based sessions, the browser may attach those cookies automatically when it sends a request to your site. Your app gets simpler. It is also why CSRF exists: another site can try to make the user’s browser send an unwanted request to your site while the user is logged in.

Same-origin policy and CORS are not a full CSRF defense. They mainly affect whether malicious frontend code can read responses or send certain request types. You still need backend protection for state-changing routes.

For cookie-authenticated apps, I usually want:

  • SameSite=Lax or stricter cookies unless the app genuinely needs cross-site cookies
  • CSRF tokens for state-changing form or API requests
  • Origin or Referer checks as a backup signal
  • no state changes on GET

Do not treat Origin as identity. Treat it as one CSRF signal for browser requests. A non-browser client can send its own Origin header.

Some browser headers are useful signals, not identity

Modern browsers also send fetch metadata headers such as Sec-Fetch-Site and Sec-Fetch-Mode in many requests. MDN notes that these are forbidden request headers, so frontend JavaScript cannot set them directly.

That makes them useful for rejecting suspicious browser-originated cross-site requests.

It does not turn them into authentication. A server-side script can still send whatever text it wants over HTTP. Your backend should use these headers as an extra browser-request filter, not as a replacement for auth, permissions, CSRF protection, and rate limits.

The checklist I use

For each backend route, I want a clear answer to these:

  • Who is the current user?
  • Which account, tenant, team, or workspace does this request operate on?
  • Is the current user allowed to do this action on this exact object?
  • Which request body fields are allowed from the client?
  • Which fields are server-owned and must be ignored or rejected?
  • What limit applies to this operation?
  • What happens if the request is replayed 20 times?
  • What does the endpoint return if the object belongs to someone else?

This is the prompt I would give a coding agent:

Review the backend API routes for places where the backend trusts the frontend.

For each route, check:
- missing authentication
- missing authorization
- object IDs not scoped to the current user/account
- request body fields that can overwrite server-owned fields
- expensive operations without server-side limits
- cookie-authenticated state changes without CSRF protection
- CORS or Origin checks being used as the main security check

Return concrete file paths, route names, exploit examples, and proposed fixes.

Then I would ask it to write direct request tests for the risky routes. A Playwright test through the UI is not enough for this. I want tests that hit the backend route with bad payloads and prove the server rejects them.

The browser is useful. Users get a safer place to run untrusted web pages. Your app gets useful signals. Plenty of dumb mistakes get blocked.

It is not guarding your backend.