<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:media="http://search.yahoo.com/mrss/"><channel><title>Access Control | FirewallSync</title><link>https://firewallsync.com/tags/access-control/</link><description>Practical cybersecurity writing for engineering teams that ship fast.</description><language>en-us</language><image><url>https://firewallsync.com/icon-512.png</url><title>FirewallSync</title><link>https://firewallsync.com/</link></image><lastBuildDate>Thu, 22 Oct 2026 18:44:00 +0000</lastBuildDate><atom:link href="https://firewallsync.com/tags/access-control/index.xml" rel="self" type="application/rss+xml"/><item><title>Mass Assignment: How to Stop Clients Setting role=admin</title><link>https://firewallsync.com/posts/mass-assignment-vulnerability-how-to-stop-clients-setting-role-admin/</link><guid>https://firewallsync.com/posts/mass-assignment-vulnerability-how-to-stop-clients-setting-role-admin/</guid><pubDate>Thu, 22 Oct 2026 18:44:00 +0000</pubDate><dc:creator>John Marshall</dc:creator><category>AppSec &amp; APIs</category><description>Mass assignment happens when a framework binds every field in a request onto an object, so a client can set fields like role, plan or owner that the UI never showed. Here is how it works, what OWASP's API Top 10 calls it now, and tested fixes in Python plus the Spring and Rails mechanisms.</description><enclosure url="https://firewallsync.com/posts/mass-assignment-vulnerability-how-to-stop-clients-setting-role-admin/cover_hu4478286509438204951.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/mass-assignment-vulnerability-how-to-stop-clients-setting-role-admin/cover_hu4478286509438204951.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/mass-assignment-vulnerability-how-to-stop-clients-setting-role-admin/cover_hu4478286509438204951.jpg" alt="A hand with a pen ticking boxes on a handwritten checklist in a notebook, against a dark blurred background" width="1200" height="630"></p><p>A user edits their display name. The browser sends <code>{&quot;display_name&quot;: &quot;Bobby&quot;}</code>. An attacker sends <code>{&quot;display_name&quot;: &quot;Bobby&quot;, &quot;role&quot;: &quot;admin&quot;}</code> to the same endpoint, and the server saves both, because the handler copies every field in the request onto the user record. That is a mass assignment vulnerability. It needs no special tooling, only a guess at a field name, and it turns an ordinary profile form into a privilege-escalation endpoint.</p>
<p><strong>Scope.</strong> Based on the OWASP Mass Assignment Cheat Sheet and the OWASP API Security Top 10 2023 entry for API3 (both read from OWASP&rsquo;s GitHub repositories), the Spring Framework data-binding documentation and the Rails Action Controller guide (main branches, read October 22, 2026). The Python example was run with Python 3.13, Flask 3.1.3 and Pydantic 2.13.5 using Flask&rsquo;s test client. The Spring and Rails snippets are illustrative and were not run.</p>
<h2 id="how-it-happens">How it happens</h2>
<p>Many frameworks bind request data onto objects automatically. OWASP&rsquo;s cheat sheet lists the names the weakness goes by: mass assignment in Ruby on Rails and Node.js, autobinding in Spring MVC and ASP.NET MVC, object injection in PHP. The underlying weakness is CWE-915, improperly controlled modification of dynamically determined object attributes.</p>
<p>The risk appears when the object you bind to has more properties than the caller should control: an ORM entity with <code>role</code>, <code>is_verified</code>, <code>org_id</code> or <code>balance</code>. The client never sees those fields in the UI, but OWASP points out that field names can be guessed or discovered and source code access is not needed. API responses often help, because they return the full object, field names included.</p>
<p>OWASP&rsquo;s cheat sheet cites GitHub&rsquo;s 2012 incident as a case study: a user was able to add his public key to an organization he didn&rsquo;t belong to and push changes to its repositories.</p>
<p>In the 2023 OWASP API Security Top 10, mass assignment is part of <strong>API3:2023 Broken Object Property Level Authorization</strong>. An endpoint is vulnerable if it lets a user read properties they shouldn&rsquo;t (formerly &ldquo;Excessive Data Exposure&rdquo;) or change, add or delete properties they shouldn&rsquo;t (formerly &ldquo;Mass Assignment&rdquo;). OWASP rates exploitability as easy and notes that fuzzing is commonly used to find hidden properties.</p>
<h2 id="a-tested-example-in-python">A tested example in Python</h2>
<p>The vulnerable version writes every key in the JSON body onto the stored user:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">from</span> flask <span style="color:#f92672">import</span> Flask, request, jsonify, g
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> pydantic <span style="color:#f92672">import</span> BaseModel, ConfigDict, EmailStr, ValidationError, Field
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>app <span style="color:#f92672">=</span> Flask(__name__)
</span></span><span style="display:flex;"><span>USERS <span style="color:#f92672">=</span> {<span style="color:#ae81ff">1</span>: {<span style="color:#e6db74">&#34;id&#34;</span>: <span style="color:#ae81ff">1</span>, <span style="color:#e6db74">&#34;email&#34;</span>: <span style="color:#e6db74">&#34;bob@example.com&#34;</span>, <span style="color:#e6db74">&#34;display_name&#34;</span>: <span style="color:#e6db74">&#34;Bob&#34;</span>,
</span></span><span style="display:flex;"><span>             <span style="color:#e6db74">&#34;role&#34;</span>: <span style="color:#e6db74">&#34;member&#34;</span>, <span style="color:#e6db74">&#34;plan&#34;</span>: <span style="color:#e6db74">&#34;free&#34;</span>, <span style="color:#e6db74">&#34;org_id&#34;</span>: <span style="color:#ae81ff">7</span>}}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">@app.before_request</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">fake_auth</span>():
</span></span><span style="display:flex;"><span>    g<span style="color:#f92672">.</span>user_id <span style="color:#f92672">=</span> <span style="color:#ae81ff">1</span>  <span style="color:#75715e"># stand-in for real authentication</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Vulnerable: every JSON key the client sends is written to the record.</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">@app.patch</span>(<span style="color:#e6db74">&#34;/v1/me&#34;</span>)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">update_me_vulnerable</span>():
</span></span><span style="display:flex;"><span>    USERS[g<span style="color:#f92672">.</span>user_id]<span style="color:#f92672">.</span>update(request<span style="color:#f92672">.</span>get_json())
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> jsonify(USERS[g<span style="color:#f92672">.</span>user_id])
</span></span></code></pre></div><p>The fixed version defines what a user may change, and rejects anything else:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#75715e"># Fixed: a dedicated input model lists the only fields a user may change,</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># and rejects anything else instead of silently ignoring it.</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">ProfileUpdate</span>(BaseModel):
</span></span><span style="display:flex;"><span>    model_config <span style="color:#f92672">=</span> ConfigDict(extra<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;forbid&#34;</span>)
</span></span><span style="display:flex;"><span>    email: EmailStr <span style="color:#f92672">|</span> <span style="color:#66d9ef">None</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">None</span>
</span></span><span style="display:flex;"><span>    display_name: str <span style="color:#f92672">|</span> <span style="color:#66d9ef">None</span> <span style="color:#f92672">=</span> Field(default<span style="color:#f92672">=</span><span style="color:#66d9ef">None</span>, max_length<span style="color:#f92672">=</span><span style="color:#ae81ff">80</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">@app.patch</span>(<span style="color:#e6db74">&#34;/v2/me&#34;</span>)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">update_me_fixed</span>():
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">try</span>:
</span></span><span style="display:flex;"><span>        changes <span style="color:#f92672">=</span> ProfileUpdate<span style="color:#f92672">.</span>model_validate(request<span style="color:#f92672">.</span>get_json())
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">except</span> ValidationError <span style="color:#66d9ef">as</span> e:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> jsonify(error<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;invalid fields&#34;</span>,
</span></span><span style="display:flex;"><span>                       fields<span style="color:#f92672">=</span>[<span style="color:#e6db74">&#34;.&#34;</span><span style="color:#f92672">.</span>join(map(str, x[<span style="color:#e6db74">&#34;loc&#34;</span>])) <span style="color:#66d9ef">for</span> x <span style="color:#f92672">in</span> e<span style="color:#f92672">.</span>errors()]), <span style="color:#ae81ff">422</span>
</span></span><span style="display:flex;"><span>    USERS[g<span style="color:#f92672">.</span>user_id]<span style="color:#f92672">.</span>update(changes<span style="color:#f92672">.</span>model_dump(exclude_unset<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>))
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> jsonify(USERS[g<span style="color:#f92672">.</span>user_id])
</span></span></code></pre></div><p>We sent <code>{&quot;display_name&quot;: &quot;Bobby&quot;, &quot;role&quot;: &quot;admin&quot;, &quot;plan&quot;: &quot;enterprise&quot;}</code> to each endpoint with Flask&rsquo;s test client:</p>
<pre tabindex="0"><code>v1 200 {&#39;display_name&#39;: &#39;Bobby&#39;, &#39;email&#39;: &#39;bob@example.com&#39;, &#39;id&#39;: 1, &#39;org_id&#39;: 7, &#39;plan&#39;: &#39;enterprise&#39;, &#39;role&#39;: &#39;admin&#39;}
v2 422 {&#39;error&#39;: &#39;invalid fields&#39;, &#39;fields&#39;: [&#39;plan&#39;, &#39;role&#39;]}
v2 200 {&#39;display_name&#39;: &#39;Bobby&#39;, &#39;email&#39;: &#39;bob@example.com&#39;, &#39;id&#39;: 1, &#39;org_id&#39;: 7, &#39;plan&#39;: &#39;free&#39;, &#39;role&#39;: &#39;member&#39;}
</code></pre><p>The last line is a legitimate request with only <code>display_name</code>, which the fixed endpoint accepts. <code>exclude_unset=True</code> means fields the client didn&rsquo;t send are left alone, so a partial update doesn&rsquo;t blank the email. Note that a client that explicitly sends <code>&quot;display_name&quot;: null</code> will clear it; add a validator if that should not be allowed. <code>EmailStr</code> needs Pydantic&rsquo;s optional <code>email</code> extra installed.</p>
<p>This is a demonstration, not production code: authentication is faked and storage is a dictionary.</p>
<h2 id="the-same-idea-in-other-frameworks">The same idea in other frameworks</h2>
<h3 id="spring">Spring</h3>
<p>The Spring documentation&rsquo;s guidance for safe binding is, in order: prefer immutable objects (records or constructor binding), or dedicated objects designed for the expected input. It says domain objects such as JPA or Hibernate entities &ldquo;are generally not safe for web binding&rdquo;, and that if you must bind to them you should declare the allowed properties:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-java" data-lang="java"><span style="display:flex;"><span><span style="color:#a6e22e">@InitBinder</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">void</span> <span style="color:#a6e22e">initBinder</span>(WebDataBinder binder) {
</span></span><span style="display:flex;"><span>    binder.<span style="color:#a6e22e">setAllowedFields</span>(<span style="color:#e6db74">&#34;firstName&#34;</span>, <span style="color:#e6db74">&#34;lastName&#34;</span>, <span style="color:#e6db74">&#34;*Address&#34;</span>);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Spring also offers <code>setDeclarativeBinding(true)</code>, which applies constructor binding always and setter binding only when <code>allowedFields</code> is set. The current documentation says <code>disallowedFields</code>, the blocklist approach, has been deprecated in Spring Framework 7.1 &ldquo;because it is fragile and easy to get out of sync with the actual properties over time&rdquo;.</p>
<p>A note from us: <code>@InitBinder</code> configures Spring&rsquo;s data binder, which handles form and query parameters bound to <code>@ModelAttribute</code> arguments. JSON request bodies read with <code>@RequestBody</code> are converted by an HTTP message converter instead, so for JSON APIs the dedicated input class is the control that matters.</p>
<h3 id="rails">Rails</h3>
<p>Strong parameters are on by default: the Rails guide says passing unpermitted parameters to mass assignment raises <code>ActiveModel::ForbiddenAttributesError</code>. Rails 8.0 added <code>params.expect</code>, which the guide calls &ldquo;the safest and most explicit way to permit parameters&rdquo;:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-ruby" data-lang="ruby"><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">user_params</span>
</span></span><span style="display:flex;"><span>  params<span style="color:#f92672">.</span>expect(<span style="color:#e6db74">user</span>: <span style="color:#f92672">[</span><span style="color:#e6db74">:display_name</span>, <span style="color:#e6db74">:email</span><span style="color:#f92672">]</span>)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">end</span>
</span></span></code></pre></div><p>The guide warns against <code>params.expect(user: {})</code>, which permits every current and future attribute under that key.</p>
<h3 id="nodejs">Node.js</h3>
<p>OWASP&rsquo;s cheat sheet shows the allowlist approach with a pick function, copying only named fields from <code>req.body</code> before constructing the model. A schema validator that rejects unknown keys does the same job.</p>
<h2 id="mistakes-that-reintroduce-the-bug">Mistakes that reintroduce the bug</h2>
<ul>
<li><strong>Blocklisting.</strong> Excluding <code>role</code> works until someone adds <code>is_staff</code>. OWASP and Spring both recommend allowlists.</li>
<li><strong>One input model for every caller.</strong> An admin endpoint may legitimately set <code>role</code>; a self-service endpoint must not. Use separate input models, or decide per field based on who is calling.</li>
<li><strong>Nested objects.</strong> Binding <code>{&quot;profile&quot;: {...}, &quot;account&quot;: {&quot;owner_id&quot;: 2}}</code> onto an object graph can reach properties several levels down. OWASP&rsquo;s cheat sheet tells you to review nested objects; Spring&rsquo;s allowed-field patterns are property paths for the same reason.</li>
<li><strong>Update paths that bypass the model.</strong> A bulk-update job, a GraphQL mutation or an internal admin API that writes the entity directly needs the same allowlist.</li>
<li><strong>Treating field allowlists as authorization.</strong> OWASP says DTOs and allowlists &ldquo;do not replace&rdquo; authorization for the requested object and operation. The <a href="/posts/idor-vulnerabilities-how-to-find-and-fix-insecure-direct-object-references/">IDOR guide</a> covers the object-level check, and <a href="/posts/broken-access-control-real-world-examples-and-how-to-prevent-it/">broken access control</a> covers the wider class.</li>
</ul>
<h2 id="how-to-test-for-it">How to test for it</h2>
<ol>
<li>For each write endpoint, compare the request the UI sends with the object the API returns. Every property in the response that isn&rsquo;t in the request is a candidate.</li>
<li>Send each candidate back in the request, one at a time: <code>role</code>, <code>is_admin</code>, <code>plan</code>, <code>org_id</code>, <code>owner_id</code>, <code>verified</code>, <code>balance</code>, <code>created_at</code>.</li>
<li>Check the stored result, not just the response code. Some vulnerable endpoints return 200 and the unchanged object, then persist the field anyway, or the reverse. OWASP notes that side-effect analysis may be needed when the property isn&rsquo;t returned.</li>
<li>Add regression tests like the one above, so a refactor that switches back to direct binding fails CI.</li>
</ol>
<p>Mass assignment is one of the items in <a href="/posts/the-most-common-api-security-misconfigurations-and-how-to-fix-them/">our list of common API security misconfigurations</a>, and the response-filtering half of API3 belongs in <a href="/posts/how-to-secure-a-public-rest-api-a-production-checklist/">our public REST API checklist</a>.</p>
<h2 id="limits-of-this-article">Limits of this article</h2>
<p>Only the Python example was run. Framework behavior depends on version and configuration; check your framework&rsquo;s documentation for the version you use. We did not independently verify the 2012 GitHub incident beyond OWASP&rsquo;s citation.</p>
<h2 id="sources-and-verification">Sources and verification</h2>
<p>Checked on October 22, 2026.</p>
<table>
  <thead>
      <tr>
          <th>Important claim</th>
          <th>Source</th>
          <th>Verification</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Definition, alternative names, CWE-915, exploitability without source code, GitHub 2012 case, allowlist over blocklist, DTOs don&rsquo;t replace authorization, review nested objects</td>
          <td><a href="https://cheatsheetseries.owasp.org/cheatsheets/Mass_Assignment_Cheat_Sheet.html">OWASP Mass Assignment Cheat Sheet</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>API3:2023 combines excessive data exposure and mass assignment; exploitability easy; fuzzing and side-effect analysis</td>
          <td><a href="https://owasp.org/API-Security/editions/2023/en/0xa3-broken-object-property-level-authorization/">OWASP API Security Top 10 2023: API3</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Spring guidance: immutable or dedicated objects; entities generally unsafe; setAllowedFields; declarativeBinding; disallowedFields deprecated in 7.1</td>
          <td><a href="https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-data-binding.html">Spring Framework: Data Binding, Model Design</a> (main-branch documentation source)</td>
          <td>Verified; the 7.1 deprecation is stated in current docs</td>
      </tr>
      <tr>
          <td>Rails ForbiddenAttributesError; params.expect introduced in Rails 8.0; warning on expect with an empty hash</td>
          <td><a href="https://guides.rubyonrails.org/action_controller_overview.html">Rails guide: Action Controller Overview</a> and the Rails 8.0 Action Pack changelog</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Python example output</td>
          <td>Run by us on October 22, 2026 (Python 3.13, Flask 3.1.3, Pydantic 2.13.5)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>@RequestBody JSON is not handled by the data binder</td>
          <td>Our note</td>
          <td>Qualified</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>Least-Privilege Access Controls That Don't Slow Teams Down</title><link>https://firewallsync.com/posts/least-privilege-access-controls-that-dont-slow-teams-down/</link><guid>https://firewallsync.com/posts/least-privilege-access-controls-that-dont-slow-teams-down/</guid><pubDate>Sat, 01 Aug 2026 00:00:00 +0000</pubDate><dc:creator>FirewallSync Editorial</dc:creator><category>Identity &amp; Access</category><description>Most least-privilege rollouts fail because they optimize for audit checklists instead of how engineers actually work. Here's a rollout order that holds up.</description><enclosure url="https://firewallsync.com/posts/least-privilege-access-controls-that-dont-slow-teams-down/cover_hu11790370285784053108.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/least-privilege-access-controls-that-dont-slow-teams-down/cover_hu11790370285784053108.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/least-privilege-access-controls-that-dont-slow-teams-down/cover_hu11790370285784053108.jpg" alt="Keys hanging from a door lock" width="1200" height="630"></p><p>Most least-privilege initiatives stall for the same reason: security teams design the policy around what an auditor wants to see, not around how engineers request and use access day to day. The result is a system everyone routes around within a month.</p>
<h2 id="start-with-role-mining-not-role-design">Start with role mining, not role design</h2>
<p>Before writing a single policy, pull 90 days of actual access logs and group people by what they <em>use</em>, not what their job title implies they should use. You&rsquo;ll almost always find that job-title-based roles overprovision by 3-5x. Role mining gives you a baseline that reflects reality, which means the policy you eventually ship won&rsquo;t immediately break someone&rsquo;s workflow.</p>
<h2 id="time-bound-elevated-access-by-default">Time-bound elevated access by default</h2>
<p>Standing admin access is the single biggest reason least-privilege programs get undone six months later — someone gets temporary access for an incident, and it never gets revoked because revocation isn&rsquo;t anyone&rsquo;s job. Build expiry into the request, not into a quarterly review. A 24-hour default that requires re-justification is far more durable than a 90-day access review that nobody actually reads.</p>
<h2 id="measure-friction-not-just-coverage">Measure friction, not just coverage</h2>
<p>Track how many access requests get approved without modification versus how many get pushback or take more than a day. If your approval time creeps up, engineers will start requesting broader roles &ldquo;just in case&rdquo; to avoid asking twice — which quietly reverses the entire program. Friction is a leading indicator that your policy has drifted from actual need.</p>
<h2 id="the-rollout-order-that-works">The rollout order that works</h2>
<ol>
<li>Mine current usage, not job descriptions</li>
<li>Ship time-bound elevation before you touch standing roles</li>
<li>Narrow standing roles only after elevation data shows what&rsquo;s actually needed</li>
<li>Review friction metrics monthly, formal access reviews quarterly</li>
</ol>
<p>Teams that reverse this order — writing the policy first and measuring friction never — are the ones still running the same over-permissioned setup a year later, just with a nicer-looking spreadsheet.</p>
]]></content:encoded></item></channel></rss>