{
"$type": "site.standard.document",
"description": "Browsers enforce useful security rules, but your backend still has to authenticate, authorize, validate, rate limit, and reject direct API calls.",
"path": "/your-browser-is-not-your-backend-s-bodyguard/",
"publishedAt": "2026-06-25T06:48:00.000Z",
"site": "at://did:plc:bryys25pc2fnagnyxqgsglhd/site.standard.publication/3mn26bjkkmh23",
"tags": [
"Web",
"Techniques"
],
"textContent": "I see this mistake a lot in small web apps. The browser blocks something, so the backend is treated as protected.\n\nI 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.\n\nWHAT THE BROWSER ACTUALLY DOES\n\nThe browser has real security rules.\n\nSame-origin policy restricts how a script loaded from one origin can interact with resources from another origin.\n\nCORS 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.\n\nThe browser enforces that rule.\n\nSay your frontend at https://app.example.com runs a request like this.\n\nawait fetch(\"https://api.example.com/admin/users\", {\n method: \"DELETE\",\n headers: {\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify({ userID: \"123\" }),\n})\n\nthe browser may block the frontend from reading the response unless the API sends the right CORS headers.\n\nThat does not mean the backend rejected the operation.\n\nThe request may still reach your server. For non-browser clients, CORS is not the gate at all:\n\ncurl -i -X DELETE \"https://api.example.com/admin/users\" \\\n -H \"Content-Type: application/json\" \\\n -H \"Authorization: Bearer stolen-or-low-privilege-token\" \\\n --data '{\"userID\":\"123\"}'\n\ncurl, Postman, a script running on a VPS, and a compromised mobile app do not care that your React app would not show the button.\n\nFRONTEND CHECKS ARE FOR HONEST USERS\n\nFrontend 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.\n\nBut none of those checks should be the reason the backend accepts or rejects the request.\n\nKeep the frontend check:\n\n// frontend\nif (file.size > 10 * 1024 * 1024) {\n throw new Error(\"File is too large\")\n}\n\nThis still has to exist:\n\n// backend\nconst maxUploadBytes = 10 * 1024 * 1024\n\nif (uploadedFile.size > maxUploadBytes) {\n return reply.status(413).send({ error: \"File is too large\" })\n}\n\nSame for plan limits:\n\n// frontend\nconst canGenerate = currentUsage.generatedThisMonth < plan.monthlyGenerations\n\nEnforce the same limit on the backend:\n\n// backend\nconst usage = await getUsageForAccount(accountID)\nconst plan = await getPlanForAccount(accountID)\n\nif (usage.generatedThisMonth >= plan.monthlyGenerations) {\n return reply.status(402).send({ error: \"Generation limit reached\" })\n}\n\nThe frontend makes the happy path nicer. The backend decides what is allowed.\n\nCHECK THE BACKEND WITH DIRECT REQUESTS\n\nWhen I review this class of bug, I start by copying requests out of DevTools and changing the values.\n\nIn Chrome or Safari:\n\n * submit the real form once\n * open the Network tab\n * copy the request as curl\n * remove or change fields\n * replay it from the terminal\n\nThen I try the requests the UI is supposed to prevent:\n\n# Can I update a server-owned field?\ncurl -i -X PATCH \"https://api.example.com/me\" \\\n -H \"Content-Type: application/json\" \\\n -H \"Authorization: Bearer $TOKEN\" \\\n --data '{\"name\":\"Hwee-Boon\",\"role\":\"admin\",\"plan\":\"enterprise\"}'\n\n# Can I access another user's object?\ncurl -i \"https://api.example.com/invoices/inv_other_user\" \\\n -H \"Authorization: Bearer $TOKEN\"\n\n# Can I run an expensive operation after the UI disables it?\nfor i in (seq 1 20)\n curl -s -X POST \"https://api.example.com/generate\" \\\n -H \"Content-Type: application/json\" \\\n -H \"Authorization: Bearer $TOKEN\" \\\n --data '{\"prompt\":\"write me a long report\"}' >/dev/null\nend\n\nUse whatever shell you use. Just stop testing only through the UI.\n\nI want to see boring failures:\n\n * 401 when the request is not authenticated\n * 403 when the user is authenticated but not allowed\n * 404 when the object should not be visible to that user\n * 413 when an upload is too large\n * 422 when the body has invalid client-controlled fields\n * 429 when the caller is over the rate limit\n\nIf 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.\n\nCORS IS NOT AUTHORIZATION\n\nCORS is often the part that confuses people because it looks like an access control system.\n\nFor example:\n\nAccess-Control-Allow-Origin: https://app.example.com\n\ntells 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.\n\nDo not write code like this:\n\nconst origin = request.headers.origin\n\nif (origin !== \"https://app.example.com\") {\n return reply.status(403).send({ error: \"Forbidden\" })\n}\n\nawait deleteProject(request.body.projectID)\n\nThat blocks some browser-based abuse. It is not enough.\n\nWrite the boring checks:\n\nconst user = await requireUser(request)\nconst project = await getProject(request.body.projectID)\n\nif (!project || project.accountID !== user.accountID) {\n return reply.status(404).send({ error: \"Not found\" })\n}\n\nif (user.role !== \"admin\") {\n return reply.status(403).send({ error: \"Forbidden\" })\n}\n\nawait deleteProject(project.id)\n\nThe exact framework does not matter. The rule does: authenticate the user, load the server-side record, check ownership and permissions, then do the work.\n\nOWASP 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.\n\nDO NOT TRUST CLIENT-OWNED FIELDS\n\nThe fastest way to create a backend bug is to accept the whole request body and pass it into your database update.\n\nAvoid this:\n\nawait db.user.update({\n where: { id: user.id },\n data: request.body,\n})\n\nThe UI may only send this:\n\n{\n \"displayName\": \"Hwee-Boon\"\n}\n\nbut an attacker can send this:\n\n{\n \"displayName\": \"Hwee-Boon\",\n \"role\": \"admin\",\n \"billingPlan\": \"enterprise\",\n \"emailVerified\": true\n}\n\nUse an allowlist:\n\nconst body = updateProfileSchema.parse(request.body)\n\nawait db.user.update({\n where: { id: user.id },\n data: {\n displayName: body.displayName,\n avatarURL: body.avatarURL,\n },\n})\n\nThis 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.\n\nOWASP's Broken Object Property Level Authorization covers this kind of overexposure and mass assignment problem.\n\nCSRF IS THE BROWSER BEING TOO HELPFUL\n\nCookies make this more subtle.\n\nIf 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.\n\nSame-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.\n\nFor cookie-authenticated apps, I usually want:\n\n * SameSite=Lax or stricter cookies unless the app genuinely needs cross-site cookies\n * CSRF tokens for state-changing form or API requests\n * Origin or Referer checks as a backup signal\n * no state changes on GET\n\nDo not treat Origin as identity. Treat it as one CSRF signal for browser requests. A non-browser client can send its own Origin header.\n\nSOME BROWSER HEADERS ARE USEFUL SIGNALS, NOT IDENTITY\n\nModern 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.\n\nThat makes them useful for rejecting suspicious browser-originated cross-site requests.\n\nIt 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.\n\nTHE CHECKLIST I USE\n\nFor each backend route, I want a clear answer to these:\n\n * Who is the current user?\n * Which account, tenant, team, or workspace does this request operate on?\n * Is the current user allowed to do this action on this exact object?\n * Which request body fields are allowed from the client?\n * Which fields are server-owned and must be ignored or rejected?\n * What limit applies to this operation?\n * What happens if the request is replayed 20 times?\n * What does the endpoint return if the object belongs to someone else?\n\nThis is the prompt I would give a coding agent:\n\nReview the backend API routes for places where the backend trusts the frontend.\n\nFor each route, check:\n- missing authentication\n- missing authorization\n- object IDs not scoped to the current user/account\n- request body fields that can overwrite server-owned fields\n- expensive operations without server-side limits\n- cookie-authenticated state changes without CSRF protection\n- CORS or Origin checks being used as the main security check\n\nReturn concrete file paths, route names, exploit examples, and proposed fixes.\n\nThen 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.\n\nThe browser is useful. Users get a safer place to run untrusted web pages. Your app gets useful signals. Plenty of dumb mistakes get blocked.\n\nIt is not guarding your backend.",
"title": "Your Browser Is Not Your Backend's Bodyguard"
}