Applications often send useful state to a client: a user role for rendering navigation, an owner identifier for displaying a record, or a flag that controls whether a button appears. The security problem begins when the server later accepts that client-supplied state as proof that an action is allowed.
A client is outside the server’s trust boundary. A browser, mobile application, API consumer, or desktop client can send requests that differ from the interface the developer intended. If changing a client-controlled field can change an authorization result, a user may gain authority that the server never granted.
The consequence can be more serious than a broken interface. A request may update another user’s object, perform an administrator-only action, bypass an approval step, or apply a privileged business rule.
This article develops one defensive mental model: client-side state may describe a request, but it must not be the source of authority for that request. You will learn how to separate claims from trusted facts, reconstruct authorization decisions on the server, and test that client-controlled fields cannot grant additional privilege.
Separate a claim from a security fact
Suppose an application sends this request when a user edits a project:
{
"project_id": "p-1042",
"name": "Quarterly report",
"owner_id": "user-27",
"is_admin": false
}Some fields are ordinary input. The user may legitimately be allowed to change name.
Other fields look like security facts. owner_id could influence who may modify the project. is_admin could influence which operations are allowed. The presence of those fields in a request does not make them trustworthy.
The key distinction is provenance: where did the value come from, and who was able to change it before the authorization decision?
If the answer is “the client,” treat the value as a claim. The server may validate it, compare it with trusted data, or ignore it, but it should not grant authority merely because the client supplied it.
A trusted security fact normally comes from a source whose integrity is protected for that decision. Examples include the authenticated principal established by the server’s session mechanism, a role loaded from an authoritative identity store, or resource ownership read from a protected data store.
See the failure as a broken trust boundary
Consider a simplified update flow:
client request
|
v
read is_admin from request
|
v
is_admin == true? ---- yes ----> allow privileged updateThe authorization rule may look explicit, but its input is controlled by the party being authorized. That makes the decision circular: the requester can influence the evidence used to decide whether the requester has permission.
The safer flow separates requested data from authoritative security state:
client request
|
+---- requested change
|
v
authenticated principal
|
+---- server-side role
+---- server-side resource ownership
|
v
authorization decision
|
allow / denyThe client still identifies what it wants to do. The server decides whether that principal may do it using facts from trusted sources.
This control reduces the risk of privilege changes caused by modified request fields, hidden form values, stale client state, or alternate clients. It does not protect against a compromised authoritative identity store, a stolen authenticated session, or an authorization rule that is itself too permissive. Those are different threats and need their own controls.
Derive authority instead of accepting it
A useful implementation rule is to distinguish intent fields from authority fields.
Intent fields describe the operation the caller wants. For example:
project_id = p-1042
new_name = Quarterly reportAuthority fields would answer questions such as:
caller_is_admin = true
caller_owns_project = true
approval_complete = trueThe client should not be able to make those statements authoritative. Instead, the server should derive them from trusted state.
A simplified handler can follow this sequence:
principal = authenticate(request)
project = load_project(request.project_id)
if not can_edit(principal, project):
reject
validate(request.new_name)
update_name(project, request.new_name)The important property is not the programming language or framework. It is the direction of the dependency: can_edit receives the authenticated principal and the server-loaded project. It does not receive request.is_admin or trust request.owner_id as evidence of permission.
If ownership itself is editable, changing ownership should be a separate authorized operation. Mixing an ordinary content edit with a client-supplied ownership change makes it harder to see that the request is asking for additional authority.
Hidden fields and disabled controls are still client-controlled
User interfaces often hide actions that a user cannot perform. That is useful for usability, but it is not an authorization control.
A hidden form field is sent by the client. A disabled button is a presentation choice. A mobile application can be modified. An API can be called without using the official interface at all.
This means the following design is incomplete:
if user is not admin:
hide "Delete account" buttonThe interface should hide or disable actions that are unavailable, but the server endpoint must independently authorize the deletion when a request arrives.
The same principle applies to values generated by frontend code. A JavaScript variable calculated from a trusted response may have been trustworthy when the server originally produced it, but once the value returns in a later request, the server must treat that copy as client-controlled unless an integrity mechanism and its security semantics explicitly make it otherwise.
Signed client state changes integrity, not every security question
Sometimes an application deliberately stores state on the client and protects it with a message authentication code or digital signature. A valid integrity check can show that protected fields were produced by a holder of the relevant signing or authentication key and were not modified without detection.
That can be useful, but it does not automatically make the state suitable for every future authorization decision.
Suppose the server issues a signed statement saying a user has the editor role. The signature can protect the statement from client modification. It does not by itself answer whether the role was revoked after issuance, whether the statement is being used for its intended audience and purpose, or whether its lifetime is acceptable for the operation now being attempted.
So there are two separate questions:
- Integrity: was this client-held value modified?
- Current authority: should this value grant this action now?
For low-risk, short-lived capabilities, carefully designed signed state may be appropriate. For decisions that require prompt revocation, current ownership, current account status, or rapidly changing policy, consulting authoritative server-side state is often the simpler and more reliable design.
Do not add home-grown signing merely to avoid a server lookup. If signed client state is part of the design, use established cryptographic libraries and define the statement’s issuer, purpose, audience, expiry, and revocation assumptions explicitly.
Be careful with stale state even when the client is honest
An attacker is not required for client-side authorization state to fail.
Imagine a project page is opened while Dana is a project administrator. The page stores can_manage_members = true. An owner removes Dana’s administrator role in another session. Dana’s original page remains open and later sends the old value back to the server.
If the server trusts that value, the old page can continue exercising authority after the authoritative role changed.
This is a freshness problem. Re-deriving the decision from current server-side state makes revocation effective according to the freshness guarantees of that state. If the server itself uses caches or replicated authorization data, those systems still need a deliberate staleness policy. Moving the decision to the server does not make stale authorization data disappear; it puts the freshness decision in infrastructure the defender can control.
For actions where rapid revocation matters, design the authorization path so that the maximum acceptable delay is explicit. For less sensitive operations, a bounded cache may be a reasonable availability and performance trade-off.
Keep writable data separate from protected attributes
A practical way to reduce mistakes is to define which request fields are writable for each operation.
Suppose a user profile contains:
display_name
locale
account_role
mfa_required
account_statusAn ordinary profile-edit endpoint may accept display_name and locale. The other fields affect security policy and should not become writable merely because a generic object-binding mechanism can populate them.
This is related to input validation, but the security decision is more specific than checking type or format. account_role = "admin" can be perfectly valid text and still be unauthorized for the caller to set.
Use operation-specific input models or explicit field allowlists so that protected attributes are not silently copied from a request into trusted state. Then authorize any legitimate security-sensitive changes through dedicated paths with the appropriate checks.
This separation also improves reviewability. A developer reading an endpoint can see both what the caller may request and which server-side facts determine whether the request is allowed.
Verify the boundary with negative tests
Authorization tests should prove that changing client-controlled state cannot create authority.
For the project example, useful tests include requests where a normal user:
- changes an
is_adminfield totrue; - supplies another user’s
owner_id; - sends a protected field that the normal interface omits;
- replays a request after the user’s role or ownership has been revoked;
- calls the endpoint directly without first loading the page that normally renders the action.
The expected result is not necessarily that every unexpected field produces the same error. Some applications reject unknown or protected fields; others ignore them. The security invariant is that client-controlled changes must not cause the server to grant authority the authenticated principal does not currently have.
Also test the positive path. A legitimate authorized user should still be able to perform the operation. Security tests that only prove rejection can miss an implementation that simply breaks the feature.
In production, log security-relevant denials with enough context to investigate patterns, while avoiding credentials, tokens, or unnecessary sensitive data. Repeated attempts to submit protected attributes can be useful detection signals, but logging is a detection control, not a substitute for server-side authorization.
Know when server-side state is not enough
Moving authorization inputs to trusted server-side sources solves one class of problem, not every access-control problem.
The server can still make a wrong decision if:
- the authenticated principal is mapped to the wrong account;
- resource ownership in the authoritative store is incorrect;
- the policy grants a role more permission than intended;
- a privileged service bypasses the same authorization path;
- authorization is checked early and relevant state changes before the action is committed.
These failure modes show why the mental model should be “use trustworthy facts for the decision,” not simply “put everything in a database.” Trust depends on how state is created, protected, updated, and consumed.
For especially sensitive operations, defense in depth may include fresh authentication, independent approval, atomic state changes, or additional audit events. Add those controls because the threat model justifies them, not because client-side state is inherently solved by more layers.
A practical design rule
When reviewing an endpoint, identify every value that can change the answer to “may this action happen?” For each value, ask who can modify it before the decision.
If the requester can modify the value, treat it as a claim rather than authority. Reconstruct the decision from the authenticated principal, authoritative resource state, and explicit server-side policy. Keep protected attributes out of ordinary writable input, and test that altered or stale client state cannot increase privilege.
The client should be free to describe what it wants. The server should remain responsible for deciding what it is allowed to do.