CWE-472: External Control of Assumed-Immutable Web Parameter
Overview
Applications often treat some client-controlled values as fixed: hidden form fields, cookies, disabled inputs, URL parameters. Nothing stops a user from changing any of them, so a server that acts on such a value without re-checking it lets that user manipulate prices, escalate privileges, and bypass business logic.
Relationship to Other CWEs
- CWE-472 (this page) - the value travels through the client in a channel the application treats as unmodifiable: a hidden field, a disabled input, a cookie, a URL parameter. MITRE marks it ALLOWED for direct mapping.
- CWE-642 (External Control of Critical State Data) - the parent. Use it when the client-controlled state is not a web parameter (a value in local storage, a workflow token, a desktop or mobile client's stored state), or when the finding spans several of its children.
- CWE-471 (Modification of Assumed-Immutable Data) - the other parent, and the non-web form of the same mistake: a value assumed constant that something can still write. No page here.
- CWE-639 (Authorization Bypass Through User-Controlled Key) - the neighbouring finding, and the one most often reported on the same line. If the tampered parameter is an identifier and the missing control is an ownership check, that is CWE-639. If it is a value the server acts on directly, such as a price, a role or a discount, it is this page.
OWASP Classification
A06:2025 - Insecure Design
Risk
High: A user who edits one of these values can set their own price through a hidden field, grant themselves a role through a cookie, abuse a discount or coupon, or bypass quantity limits and shipping costs.
Remediation Steps
Core Principle: Do not treat client-supplied parameters as immutable; recompute and verify server-side for every request.
Locate the Assumed-Immutable Parameter Vulnerability
Working from a scan finding:
- Identify which client-controlled value the reported line trusts without a server-side check.
- Look for the channels such values arrive in: hidden form fields, disabled inputs, cookies, URL parameters.
- Trace where each value is used - pricing, authorization, business logic.
- Check whether any server-side validation of that "read-only" data exists.
- Work out what changing it gains a user: bypassed limits, altered prices, escalated privileges.
Never Trust Client-Side Data (Primary Defense)
// VULNERABLE - trusts a price submitted by the client
function checkout(form):
price = to_float(form['price']) // user can modify this
quantity = to_int(form['quantity'])
total = price * quantity
// SECURE - look up the price server-side by an identifier the client can't forge a value for
function checkout(form):
product = db.get_product(form['product_id'])
price = product.price // server-side source of truth
quantity = to_int(form['quantity'])
if quantity < 1 or quantity > product.max_per_order: // the one client value left in the total
raise ValidationError('invalid quantity')
total = price * quantity
Why this works: the price now comes from the database rather than from the request, because anything the browser sends can be changed with dev tools, a proxy, or a direct HTTP request. Use client data as identifiers only - a product ID, an order ID - and look up on the server the values those identifiers name.
Replacing the price is not the whole fix, because the quantity is still the client's. It has to be, since only the caller knows how many they want. That leaves one attacker-controlled input into the amount, and an authoritative price multiplied by an unchecked quantity hands the attacker the total anyway. A negative quantity turns a charge into a credit; a zero makes the order free. Wherever a value is looked up server-side to remove one parameter, check what the surviving parameters still reach.
Store Sensitive Data Server-Side Only
// VULNERABLE - authorization decided by a cookie the browser can edit
role = request.cookie('role')
if role == 'admin':
// ...
// BETTER - read from a server-side session
role = session.get('role')
// BEST - look up the current permission from the database on every check
user = current_user()
if not user.has_role('ADMIN'):
raise AccessDeniedError()
What to store server-side:
- User roles and permissions (in session or database)
- Pricing information (in database, looked up by product ID)
- Discount percentages (in user profile or promotion table)
- Account balances and limits
- Any data that affects authorization or business logic
Validate All Parameters Against Business Rules
// even "read-only" fields must be validated - disabled/hidden is a UI hint, not a server-side control
discount = to_float(form['discount'])
if discount < 0 or discount > 0.20: // enforce the maximum
raise ValidationError('invalid discount')
user = current_user()
if discount > user.max_allowed_discount: // enforce it against this user's actual entitlement
raise ValidationError('discount exceeds user limit')
quantity = to_int(form['quantity'])
if quantity < 1 or quantity > product.max_per_order:
raise ValidationError('invalid quantity')
For the discount this is the second-best answer, and the section above says why. A discount percentage is on the server-side list because the fix that removes the weakness is to send a coupon code and look the percentage up in the promotion table, so no percentage ever crosses the client. Validating a client-supplied percentage against a cap is what to do when the value genuinely has to travel. It bounds the loss rather than removing the parameter, and it depends on every code path applying the same cap. Quantity is the honest case for this pattern: it has to come from the client, so a range check against the product's per-order limit is the whole of the control.
Validation rules:
- Range checks (min/max values)
- Business rule verification (user entitlements, order limits)
- Data type validation
- Cross-field validation (total = price x quantity)
Use Signed Tokens for Client State When Necessary
First check that you need one. A signed token is the answer only for a value the server cannot cheaply re-derive when the next request arrives. A product's price is not that value: the server holds it, and looking it up by product_id is the fix in the section above. Reaching for a token there adds a secret, an expiry and a set of bindings to get right in place of one database read.
The case that does need one is a value computed from inputs that are gone: a shipping rate quoted by a carrier's API, a currency conversion struck at a moment, a risk score from a scoring service. Re-deriving means paying for the call again and may return a different answer, so the quote itself has to survive the round trip through the client.
function sign_shipping_quote(order_id, rate, session_id):
// sign every field the value is only valid for, not the value alone
payload = json({ 'order': order_id, 'rate': rate,
'session': session_id, 'expires': now() + 900 })
return base64url(payload) + '.' + hmac_sha256(SECRET_KEY, base64url(payload))
function verify_shipping_quote(token, expected_order_id, session_id):
payload_b64, signature = split_last(token, '.')
expected = hmac_sha256(SECRET_KEY, payload_b64)
if not constant_time_equals(signature, expected): // constant-time compare, so a wrong signature costs the same as a right one
raise TamperError('tampered quote')
claims = json_parse(base64url_decode(payload_b64))
// a valid signature only says the server minted this token, not that it
// belongs on this request - check every binding before using the value
if claims.order != expected_order_id: raise TamperError('quote is for another order')
if claims.session != session_id: raise TamperError('quote belongs to another session')
if claims.expires < now(): raise TamperError('quote expired')
return to_float(claims.rate)
Why the extra fields are the point: a signature over the rate alone proves only that this application produced some token containing that number. It says nothing about which order, which customer, or when. A genuine token minted for a 2.00 letter-post quote is a valid signature for 2.00, so replaying it against a pallet shipment passes verification exactly as intended. The attacker never forges anything; they reuse something the server signed. Bind the token to every fact it is conditional on, verify each binding against the current request, and give it a short expiry so a quote collected today is not still spendable next quarter.
When to use signed tokens:
- A value computed from an external call that is expensive or non-repeatable
- Workflow state in multi-step processes, where the steps are stateless by design
- Temporary authorization grants
- Prefer server-side state when you have somewhere to put it. A session identifier is still a bearer credential, so it still needs idle and absolute expiry and rotation on privilege change, but it removes a second secret to rotate and a set of bindings that have to be checked correctly on every path
Monitor and Test for Parameter Tampering
Testing strategies:
- Use browser dev tools to modify hidden fields before submission
- Use proxy tools (Burp Suite, OWASP ZAP) to intercept and modify requests
- Test modifying disabled form fields (enable with JavaScript, then change)
- Test cookie modification: change role, permissions, user ID
- Test URL parameter tampering: change prices, quantities, discounts
- Test negative values, zero, extremely large values
Browser-based tests: from the devtools console, edit a hidden field's .value, flip a disabled field's .disabled to false and set a new value, or set document.cookie to a different role/permission value. Submit, and confirm the server rejects or ignores the modified value rather than acting on it.
Monitoring:
- Log parameter validation failures
- Alert on suspicious patterns (prices set to $0.01, discount = 100%)
- Track HMAC signature verification failures
- Monitor for repeated tampering attempts from same user/IP
- Review business metrics for anomalies (all orders $0.01)
Verification steps:
- Modify all hidden fields and verify rejection
- Change cookie values and verify they're ignored
- Complete one legitimate purchase end to end after each change. Every test above passes against an endpoint that has stopped accepting anything at all, so the accept is the assertion that separates a working fix from a broken one.
- Where a signed token is used, replay an unmodified one somewhere it should not apply - the same token against a different order, from a different session, and after its expiry has passed. A tampering test only exercises the signature; these exercise whether the token is bound to the request at all.
Common Vulnerable Patterns
// VULNERABLE - price and role travel through client-controlled channels the
// server trusts as if they couldn't have been changed:
// a hidden form field: <input type="hidden" name="price" value="99.99">
// a disabled form field: <input type="text" name="discount" value="0" disabled>
// a cookie: Set-Cookie: isAdmin=true
// a URL parameter: /purchase?item=123&price=99.99
// Attack: from the browser console -
// document.querySelector('[name=price]').value = '0.01'
// document.querySelector('[name=discount]').disabled = false
// document.cookie = 'role=admin'
// Result: the server accepts the attacker-modified price, discount, or role
// because "hidden" and "disabled" are presentation hints, not server-side controls
Why this is vulnerable: hidden and disabled are instructions to the renderer, not to the transport. A hidden field is still serialised into the request body, a cookie is still a header the client composes, and a disabled attribute is one line of console input away from being removed. All four channels deliver a value the user can set to anything, and the server has no way to tell a value it rendered a moment ago from one that was typed over it, because nothing in the request records the difference.
The rule that generalises past these four is that a value which has made a round trip through the client is input, whoever originally wrote it. That includes fields the user never sees, values the application put there itself, and anything a previous response set. Signing the value works and is the second-best answer. The better one is usually not to send it at all: the server holds the item identifier and can look the price up, which removes both the parameter and the question of whether the signature was verified on every path.