skip to content

Why is a server-side write function that a route exposes as a POST target treated as a public endpoint?

level: middleimportance: must knowfreq 70%

answer

  1. exposing it means giving it an address
  2. the render's decision does not travel
  3. assume the form does not exist
  4. identity, permission on this object, input
  5. origin check is a floor, not a fence

basics

~20 s

Exposing the function gives it a URL, and any client can send it any payload. Nothing the rendering page decided travels with the request, so identity, permission and input checks must live inside the handler.

solid answer

~50 s

When a framework makes a server function submittable, it publishes an address for it, and an address is reachable by anything that speaks HTTP — not only by the form you rendered. The request carries a method, a body and cookies; it does not carry the fact that a page decided the user was an admin. So every guarantee the UI implies has to be re-established inside the handler: who is calling, whether they may perform this action on this specific record, and whether the submitted values are actually the shape and range you expect. Hiding or disabling the control is a usability decision, not a control. An origin check helps — it lets you reject a submission another site made in the user's browser — but it is a floor, not a fence: it says nothing about identity or permission, and a non-browser client sets its own headers.

go deeper

for a junior

Remember the one-line version: making a function submittable gives it a URL, and a URL can be called by anyone with anything. The checks belong in the function.

for a middle

Explain why the rendering pass's knowledge does not travel with the next request, and name the three checks the handler owns — caller identity from the session, permission scoped to the specific target, and the shape of the submitted input.

for a senior

Demonstrate the posture: assume the form never existed, scope loads by the caller so a forbidden record is simply not found, keep rejections renderable, and treat the origin check as one layer rather than the control.

for a principal

Own the systemic version. Decide where authorisation lives so it is not re-derived per handler, how it is tested, and how a reviewer can tell at a glance that a new write did its checks rather than inheriting them from the UI.

## The mechanism forces the conclusion To make a form submit reach server code, the framework has to give that code an address. There is no other way: the browser needs a URL to put in `action`. Once that address exists, it behaves like every other URL in the system — a request to it is just bytes with a method, a path, headers, cookies and a body. Anything able to send those bytes can invoke the handler: a script in a terminal, a rewritten page in a developer console, a crawler that found the address in the markup, a replayed request from a proxy log. What does *not* travel with that request is everything the rendering pass knew. The server decided, while producing the page, that this user was an administrator and therefore rendered a delete button. That decision lived in a render that has already finished. The next request is a new one, and it carries no memory of it. ## What must live inside the handler Three checks, none of which the UI can perform on the handler's behalf: 1. **Identity.** Who is this? Read it from the request itself — the session the cookie identifies — not from anything the client submitted. A user id in a hidden field is a claim, not an identity. 2. **Permission on this object.** Not "may this role delete things" but "may *this* caller delete *this* record". Identifiers arrive in the body and can be changed freely, so the check has to be scoped to the specific target. 3. **The shape of the input.** Fields arrive as text. Presence, type, range, length and the set of allowed values are all still open questions when the handler begins, and extra fields nobody expected may be present. The useful mental test: **assume the form does not exist and the caller composed the request by hand.** Everything that still holds is a control; everything that stops holding was scenery. ## Why hiding the control is not a control | What the page does | What it changes for a direct request | |---|---| | Renders the button only for admins | nothing — the address is unchanged | | Disables the button while a write is pending | nothing — no button is involved | | Omits the form entirely for read-only users | nothing — the handler still answers | | Validates the field before submitting | nothing — the body is whatever the caller sent | Every row is worth doing for the people using your UI. Not one of them survives a request that did not come from your UI. ## What an origin check is for Most frameworks compare the submitting origin against the app's own and reject a mismatch. That addresses one specific problem: a page on someone else's site causing the user's browser to submit to your handler with the user's cookies attached. It is genuinely useful, and it is a **floor, not a fence**: - It says nothing about **who** is calling — a legitimately signed-in user of your own app passes it while doing something they should not be allowed to do. - It says nothing about **what** was submitted — the body is still unvalidated. - It constrains **browsers**, which refuse to let a page forge the header. A client that is not a browser sets or omits headers as it pleases, so for a request carrying a stolen or borrowed credential the check is not an obstacle. - It needs configuration when legitimate traffic arrives through a different host name than the app believes it is served from, which is a common source of "it works locally and fails behind the proxy". So treat it as one layer that removes a class of attack, sitting beneath the checks that actually decide the outcome. ## The consequence for how you write the handler A handler written this way opens the same way every time: establish the caller from the request, load the target object, check the caller against that object, parse and constrain the input, and only then do the work. The ordering matters — loading the object before checking permission is fine, but returning anything about it before the check leaks existence. One more consequence is worth naming: since the checks are in the handler, the UI can be as permissive as it likes without becoming a security problem, and the handler's rejection has to be renderable. A permission failure is an outcome the route must be able to show, not an assertion that crashes the process. ## What this shares with any other endpoint None of this is special to route-handled writes. It is the ordinary discipline of exposing an HTTP endpoint, which is exactly the point: the convenience of declaring the write next to the component makes it easy to forget that an endpoint is what you created. The colocation is an authoring convenience, not a privacy boundary.

  • A hidden field carries the id of the account being edited. What is wrong with trusting it?
    Nothing stops the caller from sending a different id. The field is input, not identity. Read the caller from the session, then check that this caller is allowed to act on the id that arrived — and load the record scoped to that permission rather than by id alone.
  • Does putting the write behind a session cookie make the permission check unnecessary?
    No. A cookie establishes who is calling, not what they may do. Every signed-in user of the app passes that bar, including one submitting another user's record id. Authentication and authorisation are separate questions and both have to be answered in the handler.
  • Where should the check sit relative to loading the record?
    Either order works as long as nothing about the record is returned before the check passes. A common shape is to load scoped to the caller, so a record they may not touch simply is not found, which avoids leaking that it exists.

Hiding the delete button is like taking the sign off a door and leaving it unlocked. Anyone who already knows where the door is walks straight through.

saying these in an interview costs you the question

  • Says the write is safe because the button renders only for admins
  • Trusts a user id submitted in a hidden field as identity
  • Thinks the generated address is secret because nobody types it
  • Believes the origin check covers permission or payload validity
  • Validates only in the form and skips it in the handler
  • Assumes only the app's own pages can reach the handler