The Disabled Button Is Not a Security Feature
Testing APIs directly shows you the truth about a web application: the interface is a polite suggestion, and the server is the only place a rule actually exists.
- Published
- Reading time
- 3 min read
- Length
- 532 words
- Topics
- Security, APIs, Laravel
There’s a moment in API testing that changes how you see web applications. You open Postman, copy a request the interface just made, change one value and send it again. The button that was disabled on screen, the field that was read-only, the menu item only admins could see: none of them exist anymore. There is only the request and the server’s answer.
The Interface Is One Client Among Many
In the browser, an application looks like a set of screens with rules built in. A user without permission can’t see the Approve button. A form won’t submit until the date is in the future. A price field is grayed out.
From the server’s point of view, none of that exists. The interface is one client among many, and the most polite one. Anyone with a token and a terminal is also a client:
# The Approve button is hidden for this user. The endpoint doesn't know that.
curl -X PATCH https://app.example.com/api/orders/42 \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"status": "approved"}'If the only thing standing between that request and the database is a hidden button, nothing is standing there.
Three Questions for Every Request
Testing endpoints directly turns security from a vague worry into a short list. For every request that changes something, the server has to answer three questions on its own:
- Who is this? Is the token valid, and does it belong to a real, active user?
- May they do this? Not “are they logged in”, but does their role allow approving orders at all?
- May they do it to this one? Order 42 might belong to another company. Changing the number in the URL is one of the oldest attacks on the web, and it still works wherever the server checks the user but not the object.
The third question is the one interfaces hide best, because an interface only ever shows you your own records. It never occurs to the screen that you might ask for someone else’s.
Putting Rules Where They Hold
In Laravel, a form request is a natural place to answer all three at the boundary, before any business logic runs:
class UpdateOrderRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('update', $this->route('order'));
}
public function rules(): array
{
return [
'status' => ['required', Rule::in(['draft', 'submitted'])],
'due_date' => ['required', 'date', 'after:today'],
];
}
}The policy behind can('update', ...) checks both the role and the ownership. The rules re-check everything the form already checked. Notice that approved isn’t in the list: approval is a different action with a different permission, so it gets its own endpoint. And the controller should save only $request->validated(), never the raw input, so a field slipped into the request body has nothing to attach to.
Isn’t That Duplication?
Checking the same date in the browser and on the server looks like the duplication good engineers avoid: two copies of one fact, waiting to drift. It isn’t quite. The two checks answer different questions. The browser’s check is for the person: fast, friendly feedback before they wait on a round trip. The server’s check is for the system: it decides what is allowed to become true. When the two drift, the browser’s copy is the wrong one, by definition.
That asymmetry is the whole lesson. Every rule needs exactly one place where it’s enforced, and for anything that matters, that place is the server. Everything the interface adds on top is hospitality.