<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://blog.clearview.team/feed.xml" rel="self" type="application/atom+xml" /><link href="https://blog.clearview.team/" rel="alternate" type="text/html" /><updated>2026-09-18T09:57:51+02:00</updated><id>https://blog.clearview.team/feed.xml</id><title type="html">Remote Since Forever</title><subtitle>Long-form essays from Clearview Team — on building software, shipping with small teams, and seeing past the noise.</subtitle><author><name>Clearview Team</name><email>info@clearview.team</email></author><entry><title type="html">Closing the Rate-Limit Bypass: A CloudFront + ALB Case Study</title><link href="https://blog.clearview.team/2026/closing-the-rate-limit-bypass-cloudfront-alb-case-study/" rel="alternate" type="text/html" title="Closing the Rate-Limit Bypass: A CloudFront + ALB Case Study" /><published>2026-09-14T11:00:00+02:00</published><updated>2026-09-14T11:00:00+02:00</updated><id>https://blog.clearview.team/2026/closing-the-rate-limit-bypass-cloudfront-alb-case-study</id><content type="html" xml:base="https://blog.clearview.team/2026/closing-the-rate-limit-bypass-cloudfront-alb-case-study/"><![CDATA[<p>Three weeks before a client's public launch, we were reviewing the security of their Express API — the usual sweep of every endpoint the public would be able to reach. The login rate limiter was already in place. The intent was right. The code worked. And yet, with one HTTP header on each request, an attacker could make unlimited login attempts and the limiter would count every one as a different visitor.</p>

<p>One line of <code class="language-plaintext highlighter-rouge">curl</code> did it — the command-line tool engineers use to send HTTP requests by hand, the same kind your browser sends when it loads a page:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-H</span> <span class="s2">"X-Forwarded-For: 203.0.113.7"</span> https://api.client.example/auth/signin
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">-H</code> flag attaches a header to the request. <code class="language-plaintext highlighter-rouge">X-Forwarded-For</code> is the header proxies use to record the visitor's real IP address as the request travels through them. We will come back to why setting it by hand breaks the limiter.</p>

<p>The rest of this post is the reproduction recipe, the root cause, and the fix that closed the gap without breaking real traffic.</p>

<h2 id="the-client-architecture">The Client Architecture</h2>

<p>The intended request path looked like this:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Client → CloudFront → ALB → Express (Node.js)
</code></pre></div></div>

<p>What each of those four boxes actually is:</p>

<ul>
  <li><strong>Client</strong> — the visitor's browser, or an attacker's terminal.</li>
  <li><strong>CloudFront</strong> — AWS's content delivery network. It sits at the edge of the internet, caches static files, and forwards everything else inward.</li>
  <li><strong>ALB</strong> — Application Load Balancer. AWS's traffic distributor. Takes requests from CloudFront and routes them to whatever application server is healthy.</li>
  <li><strong>Express (Node.js)</strong> — the application itself. The login endpoint and the rate limiter live here.</li>
</ul>

<p>Two proxy hops between the visitor and the application. Each proxy <em>prepends</em> — adds to the front of — the <code class="language-plaintext highlighter-rouge">X-Forwarded-For</code> header. CloudFront writes the visitor's real IP at the front first, then the ALB writes CloudFront's IP at the front of that. By the time the request reaches Express, the header reads:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>X-Forwarded-For: &lt;real-client-ip&gt;, &lt;cloudfront-edge-ip&gt;
</code></pre></div></div>

<p>Express needs the real client IP for rate limiting. That is what <code class="language-plaintext highlighter-rouge">trust proxy</code> exists for:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// express.ts:12</span>
<span class="nx">app</span><span class="p">.</span><span class="nf">set</span><span class="p">(</span><span class="dl">'</span><span class="s1">trust proxy</span><span class="dl">'</span><span class="p">,</span> <span class="mi">2</span><span class="p">);</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">trust proxy: 2</code> tells Express that there are exactly two trusted proxies between the visitor and the application, and to skip the last two IPs in the <code class="language-plaintext highlighter-rouge">X-Forwarded-For</code> header (XFF for short) to find the real one:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>XFF: &lt;real-client&gt;, &lt;cloudfront&gt;
         ↑              ↑
      req.ip         skip (proxy 1)
                    ALB adds its own (proxy 2, not in XFF)
</code></pre></div></div>

<p>Express reads the real client IP. The rate limiter counts per IP. Everything works — until you skip the proxy chain entirely.</p>

<h2 id="what-we-caught">What We Caught</h2>

<p>The first signal was the ALB responding directly:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-v</span> <span class="s2">"</span><span class="nv">$API</span><span class="s2">/api/config"</span> 2&gt;&amp;1 | <span class="nb">grep</span> <span class="nt">-iE</span> <span class="s2">"server|x-cache|via"</span>
<span class="c"># server: awselb/2.0</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">server: awselb/2.0</code> with no <code class="language-plaintext highlighter-rouge">x-cache</code> or <code class="language-plaintext highlighter-rouge">via</code> header. The request had reached the ALB without going through CloudFront. That changes the math. When traffic skips CloudFront, there is only one proxy hop, not two.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Direct to ALB (1 hop):
  XFF: &lt;attacker-injected-ip&gt;
           ↑
        Express reads this as req.ip
        (skips 2, but there is only 1 real proxy)
</code></pre></div></div>

<p>With <code class="language-plaintext highlighter-rouge">trust proxy: 2</code>, Express trims two entries from the right of <code class="language-plaintext highlighter-rouge">X-Forwarded-For</code>. There was only one real proxy. So Express reached into the part of the header the <em>attacker</em> controls and used whatever the attacker put there as the source IP.</p>

<p>We confirmed the rate limiter worked normally first:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Five sign-in attempts from the same IP — rate limited correctly</span>
<span class="k">for </span>i <span class="k">in</span> <span class="si">$(</span><span class="nb">seq </span>1 5<span class="si">)</span><span class="p">;</span> <span class="k">do
  </span>curl <span class="nt">-s</span> <span class="nt">-o</span> /dev/null <span class="nt">-w</span> <span class="s2">"%{http_code} "</span> <span class="nt">-X</span> POST <span class="se">\</span>
    <span class="s2">"</span><span class="nv">$API</span><span class="s2">/api/auth/signin"</span> <span class="se">\</span>
    <span class="nt">-H</span> <span class="s2">"Content-Type: application/json"</span> <span class="se">\</span>
    <span class="nt">-d</span> <span class="s1">'{"email":"test@example.com","password":"wrong"}'</span>
<span class="k">done</span>
<span class="c"># 429 429 429 429 429</span>
</code></pre></div></div>

<p>Then with rotating fake IPs:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Five attempts with rotating X-Forwarded-For — limiter sees five different "users"</span>
<span class="k">for </span>i <span class="k">in</span> <span class="si">$(</span><span class="nb">seq </span>1 5<span class="si">)</span><span class="p">;</span> <span class="k">do
  </span>curl <span class="nt">-s</span> <span class="nt">-o</span> /dev/null <span class="nt">-w</span> <span class="s2">"%{http_code} "</span> <span class="nt">-X</span> POST <span class="se">\</span>
    <span class="s2">"</span><span class="nv">$API</span><span class="s2">/api/auth/signin"</span> <span class="se">\</span>
    <span class="nt">-H</span> <span class="s2">"Content-Type: application/json"</span> <span class="se">\</span>
    <span class="nt">-H</span> <span class="s2">"X-Forwarded-For: 10.0.</span><span class="k">$((</span>RANDOM <span class="o">%</span> <span class="m">255</span><span class="k">))</span><span class="s2">.</span><span class="k">$((</span>RANDOM <span class="o">%</span> <span class="m">255</span><span class="k">))</span><span class="s2">"</span> <span class="se">\</span>
    <span class="nt">-d</span> <span class="s1">'{"email":"test@example.com","password":"wrong"}'</span>
<span class="k">done</span>
<span class="c"># 400 400 400 400 400</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">400</code>s, not <code class="language-plaintext highlighter-rouge">429</code>s. Every request reached the sign-in logic. The rate limiter saw each request as a different IP because Express trusted the attacker's <code class="language-plaintext highlighter-rouge">X-Forwarded-For</code>. Each fake IP carries its own counter, and no counter ever hits the threshold.</p>

<p>Brute force went through unimpeded. We reproduced it in staging and in production.</p>

<h2 id="why-the-alb-was-reachable-at-all">Why the ALB Was Reachable At All</h2>

<p>The ALB security group was supposed to restrict inbound traffic to CloudFront only. The intended rule was present:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">ingress</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"lb_cloudfront_https_ingress_only"</span>
  <span class="nx">from_port</span>   <span class="o">=</span> <span class="mi">443</span>
  <span class="nx">to_port</span>     <span class="o">=</span> <span class="mi">443</span>
  <span class="nx">protocol</span>    <span class="o">=</span> <span class="s2">"tcp"</span>
  <span class="nx">prefix_list</span> <span class="o">=</span> <span class="p">[</span><span class="nx">data</span><span class="p">.</span><span class="nx">aws_ec2_managed_prefix_list</span><span class="p">.</span><span class="nx">cloudfront</span><span class="p">.</span><span class="nx">id</span><span class="p">]</span>
<span class="p">}</span>
</code></pre></div></div>

<p>But three other rules were <em>also</em> present, left over from an earlier migration:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">ingress</span> <span class="p">{</span>
  <span class="nx">from_port</span>   <span class="o">=</span> <span class="mi">80</span>
  <span class="nx">to_port</span>     <span class="o">=</span> <span class="mi">80</span>
  <span class="nx">protocol</span>    <span class="o">=</span> <span class="s2">"tcp"</span>
  <span class="nx">cidr_blocks</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"0.0.0.0/0"</span><span class="p">]</span>
<span class="p">}</span>

<span class="nx">ingress</span> <span class="p">{</span>
  <span class="nx">from_port</span>   <span class="o">=</span> <span class="mi">443</span>
  <span class="nx">to_port</span>     <span class="o">=</span> <span class="mi">443</span>
  <span class="nx">protocol</span>    <span class="o">=</span> <span class="s2">"tcp"</span>
  <span class="nx">cidr_blocks</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"0.0.0.0/0"</span><span class="p">]</span>
<span class="p">}</span>

<span class="nx">ingress</span> <span class="p">{</span>
  <span class="nx">from_port</span>   <span class="o">=</span> <span class="mi">444</span>
  <span class="nx">to_port</span>     <span class="o">=</span> <span class="mi">444</span>
  <span class="nx">protocol</span>    <span class="o">=</span> <span class="s2">"tcp"</span>
  <span class="nx">cidr_blocks</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"0.0.0.0/0"</span><span class="p">]</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Ports 80, 443, and 444 open to the world. Security groups are additive — <strong>if any rule allows traffic, it is allowed</strong>. The CloudFront-only rule was correct. It was also useless, because the permissive rules let everyone in alongside it.</p>

<h2 id="the-fix-isnt-dropping-trust-proxy">The Fix Isn't Dropping <code class="language-plaintext highlighter-rouge">trust proxy</code></h2>

<p>The reflex when a <code class="language-plaintext highlighter-rouge">trust proxy: 2</code> system gets bypassed is to drop the number:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">app</span><span class="p">.</span><span class="nf">set</span><span class="p">(</span><span class="dl">'</span><span class="s1">trust proxy</span><span class="dl">'</span><span class="p">,</span> <span class="mi">1</span><span class="p">);</span>  <span class="c1">// We did NOT do this.</span>
</code></pre></div></div>

<p>That breaks the rest of the stack. With <code class="language-plaintext highlighter-rouge">trust proxy: 1</code> and legitimate traffic going through CloudFront + ALB (two real hops), Express would skip only one proxy and read <strong>CloudFront's edge IP</strong> as the source. Every user behind the same CloudFront edge node would share a single rate-limit counter. One user fails to log in five times, the entire region gets locked out.</p>

<p>The real fix is upstream: close the ALB to direct traffic and re-enforce the assumption that two-hop is always the path.</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Keep only this ingress rule for HTTPS to the ALB.</span>
<span class="nx">ingress</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"CloudFront prefix list only"</span>
  <span class="nx">from_port</span>   <span class="o">=</span> <span class="mi">443</span>
  <span class="nx">to_port</span>     <span class="o">=</span> <span class="mi">443</span>
  <span class="nx">protocol</span>    <span class="o">=</span> <span class="s2">"tcp"</span>
  <span class="nx">prefix_list</span> <span class="o">=</span> <span class="p">[</span><span class="nx">data</span><span class="p">.</span><span class="nx">aws_ec2_managed_prefix_list</span><span class="p">.</span><span class="nx">cloudfront</span><span class="p">.</span><span class="nx">id</span><span class="p">]</span>
<span class="p">}</span>

<span class="c1"># Remove every 0.0.0.0/0 ingress rule.</span>
</code></pre></div></div>

<p>After the apply, a direct <code class="language-plaintext highlighter-rouge">curl</code> against the ALB DNS name gets a connection refused. All traffic flows through CloudFront. <code class="language-plaintext highlighter-rouge">trust proxy: 2</code> is correct again. The rate limiter counts the right IP.</p>

<h2 id="defense-in-depth--what-we-added-alongside">Defense in Depth — What We Added Alongside</h2>

<p>We did not stop at the security group fix. Two further layers went in alongside it, so that a future regression does not, on its own, re-open the bypass.</p>

<aside class="callout">
  <p><strong>One bad security-group edit away.</strong> A misconfigured security group is the most common single point of failure for this whole class of bug. The next two layers exist so that a future engineer accidentally re-opening port 443 to the world does not, on its own, re-open the rate-limit bypass.</p>
</aside>

<h3 id="1-cloudfront-custom-header-validation-at-the-app-layer">1. CloudFront custom-header validation at the app layer</h3>

<p>CloudFront can sign every origin request with a custom header. The application then refuses to serve any request that does not carry it.</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># CloudFront distribution origin config</span>
<span class="nx">custom_header</span> <span class="p">{</span>
  <span class="nx">name</span>  <span class="o">=</span> <span class="s2">"X-CloudFront-Secret"</span>
  <span class="nx">value</span> <span class="o">=</span> <span class="s2">"&lt;long-random-string&gt;"</span>
<span class="p">}</span>
</code></pre></div></div>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Express middleware, runs before route handlers</span>
<span class="nx">app</span><span class="p">.</span><span class="nf">use</span><span class="p">((</span><span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">,</span> <span class="nx">next</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="k">if </span><span class="p">(</span><span class="nx">req</span><span class="p">.</span><span class="nx">headers</span><span class="p">[</span><span class="dl">'</span><span class="s1">x-cloudfront-secret</span><span class="dl">'</span><span class="p">]</span> <span class="o">!==</span> <span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">CF_SECRET</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">return</span> <span class="nx">res</span><span class="p">.</span><span class="nf">status</span><span class="p">(</span><span class="mi">403</span><span class="p">).</span><span class="nf">send</span><span class="p">(</span><span class="dl">'</span><span class="s1">Direct access not allowed</span><span class="dl">'</span><span class="p">);</span>
  <span class="p">}</span>
  <span class="nf">next</span><span class="p">();</span>
<span class="p">});</span>
</code></pre></div></div>

<p>Even if the security group regresses, non-CloudFront traffic is rejected at the application layer. The secret is rotated quarterly via Terraform and a CloudFront cache invalidation.</p>

<h3 id="2-account-based-rate-limiting-on-auth-endpoints">2. Account-based rate limiting on auth endpoints</h3>

<p>Per-IP rate limiting assumes one person per IP. That assumption is fragile — corporate NATs, mobile carriers, and shared offices all break it. We added a second limiter keyed by <strong>email address</strong> on the sign-in endpoint:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">loginLimiter</span> <span class="o">=</span> <span class="nf">rateLimit</span><span class="p">({</span>
  <span class="na">keyGenerator</span><span class="p">:</span> <span class="p">(</span><span class="nx">req</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">req</span><span class="p">.</span><span class="nx">body</span><span class="p">.</span><span class="nx">email</span> <span class="o">||</span> <span class="nx">req</span><span class="p">.</span><span class="nx">ip</span><span class="p">,</span>
  <span class="na">max</span><span class="p">:</span> <span class="mi">5</span><span class="p">,</span>
  <span class="na">windowMs</span><span class="p">:</span> <span class="mi">15</span> <span class="o">*</span> <span class="mi">60</span> <span class="o">*</span> <span class="mi">1000</span><span class="p">,</span>
<span class="p">});</span>
</code></pre></div></div>

<p>Now even if the attacker manages to rotate source IPs through some other path, the target account is protected after five attempts. The legitimate user gets a clear "too many attempts" response and an account-recovery email instead of a silent brute force in the background.</p>

<h2 id="what-we-shipped-back">What We Shipped Back</h2>

<p>We did not hand the client a thirty-page report. We handed them three things, all merged in the same sprint as the finding:</p>

<ol>
  <li>The reproduction recipe above, copy-pasteable, run against staging.</li>
  <li>The Terraform diff that removes the <code class="language-plaintext highlighter-rouge">0.0.0.0/0</code> ingress rules.</li>
  <li>The middleware diff that adds the CloudFront-secret check.</li>
</ol>

<p>We re-ran the reproduction after the apply landed and got <code class="language-plaintext highlighter-rouge">429</code>s back across the board. End-to-end from "this is broken" to "this is closed" was four working days — and on the client's side, the only ask was <em>"can we point you at the staging environment for a day?"</em> The rest is what they pay us for.</p>

<h2 id="the-checklist-we-carry-forward">The Checklist We Carry Forward</h2>

<p>Five checks we now run on every Express + ALB + CloudFront engagement.</p>

<ol>
  <li><strong><code class="language-plaintext highlighter-rouge">trust proxy: N</code> is only correct if there are <em>always exactly</em> N proxies between the client and the application.</strong> Any path that reduces the hop count makes the source IP attacker-controlled.</li>
  <li><strong>The CloudFront-prefix-list ingress rule is not a filter on its own.</strong> It is one allow-rule alongside whatever else is in the security group. Add it, <em>and</em> remove every <code class="language-plaintext highlighter-rouge">0.0.0.0/0</code> rule that competes with it.</li>
  <li><strong>Sign CloudFront origin requests with a header secret</strong> and check it in Express middleware. A defense-in-depth measure that survives security-group regressions.</li>
  <li><strong>Rate-limit auth endpoints by account, not just by IP.</strong> IPs lie. Account identifiers do not.</li>
  <li><strong>Reproduce the bypass from a fresh shell before assigning severity.</strong> The reproduction recipe is what a fix gets validated against — it is worth writing carefully even when the conclusion is obvious.</li>
</ol>

<h2 id="how-to-check-your-own-setup">How To Check Your Own Setup</h2>

<p>If you are running the same shape — Express behind CloudFront and an ALB — three commands tell you whether you have the same gap:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 1. Is the ALB reachable directly?</span>
curl <span class="nt">-sI</span> <span class="s2">"https://api.client.example/health"</span> | <span class="nb">grep</span> <span class="nt">-iE</span> <span class="s2">"server|via|x-cache"</span>
<span class="c"># server: awselb/2.0  →  yes, you are hitting the ALB directly</span>

<span class="c"># 2. Does X-Forwarded-For rotation slip past rate limiting?</span>
<span class="k">for </span>i <span class="k">in</span> <span class="si">$(</span><span class="nb">seq </span>1 10<span class="si">)</span><span class="p">;</span> <span class="k">do
  </span>curl <span class="nt">-s</span> <span class="nt">-o</span> /dev/null <span class="nt">-w</span> <span class="s2">"%{http_code} "</span> <span class="se">\</span>
    <span class="nt">-H</span> <span class="s2">"X-Forwarded-For: 10.0.</span><span class="k">$((</span>RANDOM%255<span class="k">))</span><span class="s2">.</span><span class="k">$((</span>RANDOM%255<span class="k">))</span><span class="s2">"</span> <span class="se">\</span>
    <span class="s2">"https://api.client.example/api/auth/signin"</span>
<span class="k">done</span>
<span class="c"># 429 every time = fine.  Mix of 400/401 = problem.</span>

<span class="c"># 3. Inspect the ALB security group</span>
aws ec2 describe-security-groups <span class="nt">--group-ids</span> sg-xxx <span class="se">\</span>
  <span class="nt">--query</span> <span class="s1">'SecurityGroups[].IpPermissions[].IpRanges[].CidrIp'</span>
<span class="c"># 0.0.0.0/0 on port 443 = the CloudFront-only rule is doing nothing.</span>
</code></pre></div></div>

<h2 id="common-aws-waf-rate-limit-questions">Common AWS WAF rate-limit questions</h2>

<p>Four questions we hit while working through this — each answered
against the same composite-key rule pattern from the fix section.</p>

<h3 id="how-do-i-rate-limit-per-hostname-on-aws-waf">How do I rate-limit per hostname on AWS WAF?</h3>

<p>Add <code class="language-plaintext highlighter-rouge">HTTP_HEADER: Host</code> as one of the aggregation keys in a
<code class="language-plaintext highlighter-rouge">RateBasedStatement</code> <code class="language-plaintext highlighter-rouge">CustomKey</code>. The rule then counts requests per
distinct <code class="language-plaintext highlighter-rouge">Host:</code> value instead of per source IP, so <code class="language-plaintext highlighter-rouge">api.example.com</code>
and <code class="language-plaintext highlighter-rouge">admin.example.com</code> behind the same WAF get independent budgets.
Combine with a <code class="language-plaintext highlighter-rouge">ForwardedIP</code> key for a per-host + per-client counter.</p>

<h3 id="can-aws-waf-rate-limit-on-a-1-minute-window-instead-of-5">Can AWS WAF rate-limit on a 1-minute window instead of 5?</h3>

<p>Not directly — <code class="language-plaintext highlighter-rouge">RateBasedStatement</code> supports a fixed set of
evaluation windows (<code class="language-plaintext highlighter-rouge">60</code>, <code class="language-plaintext highlighter-rouge">120</code>, <code class="language-plaintext highlighter-rouge">300</code>, <code class="language-plaintext highlighter-rouge">600</code> seconds as of 2026).
<code class="language-plaintext highlighter-rouge">EvaluationWindowSec: 60</code> gets you the 1-minute window; the older
5-minute-only limit was removed. If you need a sub-minute window,
that is Lambda-at-edge territory, not WAF.</p>

<h3 id="how-do-i-rate-limit-by-url-path-segment">How do I rate-limit by URL path segment?</h3>

<p>Use <code class="language-plaintext highlighter-rouge">Scope-down statement</code> on the rate-based rule with a
<code class="language-plaintext highlighter-rouge">ByteMatchStatement</code> on <code class="language-plaintext highlighter-rouge">URI_PATH</code> (starts-with, contains, or a
regex). The rate rule only counts requests matching the scope-down
predicate, so <code class="language-plaintext highlighter-rouge">/api/auth/*</code> and <code class="language-plaintext highlighter-rouge">/api/search/*</code> get separate
budgets. Composite keys on <code class="language-plaintext highlighter-rouge">HTTP_HEADER: Host</code> + path prefix let a
single WAF instance run distinct rules per route family.</p>

<h3 id="how-do-i-exclude-a-known-good-ip-from-an-aws-waf-rate-rule">How do I exclude a known good IP from an AWS WAF rate rule?</h3>

<p>Two shapes. (a) An <strong>IPSet</strong> rule that allow-listed IPs (your
office egress, a partner's server pool) evaluated <strong>before</strong> the
rate rule with <code class="language-plaintext highlighter-rouge">Action: Allow</code> and <code class="language-plaintext highlighter-rouge">Priority</code> set lower — WAF short-
circuits on first match. (b) A <code class="language-plaintext highlighter-rouge">Scope-down statement</code> on the rate
rule with a <code class="language-plaintext highlighter-rouge">NotStatement</code> wrapping the <code class="language-plaintext highlighter-rouge">IPSetReferenceStatement</code>
so the rate counter never even increments for those IPs.</p>

<aside class="post-cta">
  <h2 id="we-could-look-at-yours">We Could Look At Yours</h2>

  <p>If you have an Express + ALB + CloudFront stack and you are not sure whether your rate limiter is counting real visitors or attacker-controlled fake ones — that is the kind of question we like getting an email about. At <strong>Clearview Team</strong>, the same engineer reads the diff every sprint, runs the same <code class="language-plaintext highlighter-rouge">curl</code> matrix before every release, and the fix usually lands in the same sprint as the finding.</p>

  <p><a href="/work-with-us/">Scope an engagement →</a></p>
</aside>]]></content><author><name>Nedim Hadzimahmutovic</name></author><category term="aws" /><category term="cloudfront" /><category term="alb" /><category term="express" /><category term="devsecops" /><category term="rate-limiting" /><category term="security" /><category term="web-api-security" /><category term="aws-devops" /><category term="case-study" /><summary type="html"><![CDATA[Three weeks before a client's public launch, we were reviewing the security of their Express API — the usual sweep of every endpoint the public would be able to reach. The login rate limiter was already in place. The intent was right. The code worked. And yet, with one HTTP header on each request, an attacker could make unlimited login attempts and the limiter would count every one as a different visitor.]]></summary></entry><entry><title type="html">Replacing a Wildcard CORS Policy: An Express Allowlist Case Study</title><link href="https://blog.clearview.team/2026/replacing-a-wildcard-cors-policy-an-express-allowlist-case-study/" rel="alternate" type="text/html" title="Replacing a Wildcard CORS Policy: An Express Allowlist Case Study" /><published>2026-08-31T11:00:00+02:00</published><updated>2026-08-31T11:00:00+02:00</updated><id>https://blog.clearview.team/2026/replacing-a-wildcard-cors-policy-an-express-allowlist-case-study</id><content type="html" xml:base="https://blog.clearview.team/2026/replacing-a-wildcard-cors-policy-an-express-allowlist-case-study/"><![CDATA[<p>A client was weeks from going live with their Express API. We had staging access and a brief to check every endpoint the public would be able to reach. The CORS configuration looked clean.</p>

<p>CORS, in one sentence, is the browser's rule about <em>which other websites</em> are allowed to send authenticated requests to your API. Set it too narrow and your own subdomains can't talk to the backend. Set it too wide and any website on the internet can.</p>

<p>The team had already moved past the obvious mistake of allowing every site on the internet (<code class="language-plaintext highlighter-rouge">origin: "*"</code>). Instead, they had written a small text pattern (called a <em>regex</em>) that was meant to allow only the client's own subdomains through.</p>

<p>The regex was the problem.</p>

<p>The rest is the configuration we found, why <em>"matches the regex"</em> is not the same as <em>"is safe to trust,"</em> and the explicit allowlist we shipped instead — including the answer every frontend team wants the moment they hear <em>"allowlist"</em>: <em>"what happens to our feature-branch preview URLs?"</em></p>

<h2 id="the-configuration-we-found">The Configuration We Found</h2>

<p>The CORS middleware was wired up like this:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// cors.config.ts</span>
<span class="kd">const</span> <span class="nx">corsOptions</span> <span class="o">=</span> <span class="p">{</span>
  <span class="na">origin</span><span class="p">:</span> <span class="sr">/^https:</span><span class="se">\/\/([</span><span class="sr">a-z0-9-</span><span class="se">]</span><span class="sr">+</span><span class="se">\.)?</span><span class="sr">client</span><span class="se">\.</span><span class="sr">com$/</span><span class="p">,</span>
  <span class="na">credentials</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
  <span class="na">methods</span><span class="p">:</span> <span class="p">[</span><span class="dl">'</span><span class="s1">GET</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">POST</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">PUT</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">DELETE</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">PATCH</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">OPTIONS</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">HEAD</span><span class="dl">'</span><span class="p">],</span>
<span class="p">};</span>
</code></pre></div></div>

<p>That regex matches <code class="language-plaintext highlighter-rouge">https://X.client.com</code> for any subdomain <code class="language-plaintext highlighter-rouge">X</code> made of lowercase letters, digits, and hyphens. It also matches the bare apex domain <code class="language-plaintext highlighter-rouge">https://client.com</code>. The team's intent was clear: trust the client's own properties, reject everything else.</p>

<p>What it actually does is accept any subdomain that <em>could</em> be registered or pointed at the client's domain in the future — including ones that do not exist yet and ones that are owned by someone else.</p>

<h2 id="how-we-demonstrated-the-problem">How We Demonstrated the Problem</h2>

<p>A quick test from the curl side:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Legitimate origin — accepted, as expected.</span>
curl <span class="nt">-sI</span> <span class="s2">"</span><span class="nv">$API</span><span class="s2">/api/config"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Origin: https://app.client.com"</span> <span class="se">\</span>
  | <span class="nb">grep</span> <span class="nt">-i</span> access-control
<span class="c"># access-control-allow-origin: https://app.client.com</span>
<span class="c"># access-control-allow-credentials: true</span>

<span class="c"># A subdomain that does not exist — also accepted.</span>
curl <span class="nt">-sI</span> <span class="s2">"</span><span class="nv">$API</span><span class="s2">/api/config"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Origin: https://anything.client.com"</span> <span class="se">\</span>
  | <span class="nb">grep</span> <span class="nt">-i</span> access-control
<span class="c"># access-control-allow-origin: https://anything.client.com</span>
<span class="c"># access-control-allow-credentials: true</span>
</code></pre></div></div>

<p>The middleware reflects the supplied <code class="language-plaintext highlighter-rouge">Origin</code> header back as <code class="language-plaintext highlighter-rouge">Access-Control-Allow-Origin</code>, <em>and</em> sets <code class="language-plaintext highlighter-rouge">Access-Control-Allow-Credentials: true</code>, <em>and</em> accepts the request. The browser will happily send cookies and authorisation headers along with cross-origin requests from any matching subdomain.</p>

<p>CORS with credentials means the browser is authorising the cross-origin destination to act on the user's behalf with their session.</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Imagine this running on evil.client.com — a subdomain registered by an attacker</span>
<span class="c1">// after the client lets a wildcard DNS record lapse, or after a takeover.</span>
<span class="nf">fetch</span><span class="p">(</span><span class="dl">'</span><span class="s1">https://api.client.com/user/profile</span><span class="dl">'</span><span class="p">,</span> <span class="p">{</span>
  <span class="na">credentials</span><span class="p">:</span> <span class="dl">'</span><span class="s1">include</span><span class="dl">'</span><span class="p">,</span>
<span class="p">})</span>
  <span class="p">.</span><span class="nf">then</span><span class="p">((</span><span class="nx">r</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">r</span><span class="p">.</span><span class="nf">json</span><span class="p">())</span>
  <span class="p">.</span><span class="nf">then</span><span class="p">((</span><span class="nx">data</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nf">sendToMyServer</span><span class="p">(</span><span class="nx">data</span><span class="p">));</span>
</code></pre></div></div>

<p>The legitimate user is on <code class="language-plaintext highlighter-rouge">app.client.com</code>. The malicious code is on <code class="language-plaintext highlighter-rouge">evil.client.com</code>. Both match the regex. The browser sends the user's session cookies along. The API returns the profile data. The attacker exfiltrates it.</p>

<h2 id="the-preflight-was-also-verbose">The Preflight Was Also Verbose</h2>

<p>While running the test matrix, we noticed the preflight response was generous:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-sI</span> <span class="nt">-X</span> OPTIONS <span class="s2">"</span><span class="nv">$API</span><span class="s2">/user"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Origin: https://evil.client.com"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Access-Control-Request-Method: DELETE"</span>
<span class="c"># access-control-allow-methods: GET,POST,PUT,DELETE,PATCH,OPTIONS,HEAD</span>
<span class="c"># access-control-allow-credentials: true</span>
</code></pre></div></div>

<p>Every HTTP method advertised, credentials allowed. A preflight response is an explicit invitation list — listing methods the application does not even use on most endpoints tells an attacker which verbs to try.</p>

<h2 id="why-this-happens--the-regex-trap">Why This Happens — The Regex Trap</h2>

<p>The regex is technically correct for the stated intent: "match any subdomain of <code class="language-plaintext highlighter-rouge">client.com</code>." The trap is that "any subdomain" includes subdomains that</p>

<ul>
  <li>do not exist yet,</li>
  <li>could be registered by an attacker,</li>
  <li>could be taken over via a dangling DNS record,</li>
  <li>could be pointed at attacker-controlled infrastructure if a wildcard DNS entry is misconfigured.</li>
</ul>

<p>Regex CORS configurations are routinely flagged because they conflate <em>syntactic match</em> with <em>semantic trust</em>. The regex says "the name looks right." It cannot say "the name is owned by us."</p>

<h2 id="the-fix-we-shipped--an-explicit-allowlist">The Fix We Shipped — An Explicit Allowlist</h2>

<p>We replaced the regex with a list of strings:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// cors.config.ts</span>
<span class="kd">const</span> <span class="nx">allowedOrigins</span> <span class="o">=</span> <span class="p">[</span>
  <span class="dl">'</span><span class="s1">https://app.client.com</span><span class="dl">'</span><span class="p">,</span>
  <span class="dl">'</span><span class="s1">https://www.client.com</span><span class="dl">'</span><span class="p">,</span>
  <span class="dl">'</span><span class="s1">https://admin.client.com</span><span class="dl">'</span><span class="p">,</span>
  <span class="dl">'</span><span class="s1">https://api.client.com</span><span class="dl">'</span><span class="p">,</span>
<span class="p">];</span>

<span class="c1">// Feature-branch deployments add origins via env var at boot.</span>
<span class="kd">const</span> <span class="nx">featureBranchOrigins</span> <span class="o">=</span>
  <span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">FEATURE_BRANCH_ORIGINS</span><span class="p">?.</span><span class="nf">split</span><span class="p">(</span><span class="dl">'</span><span class="s1">,</span><span class="dl">'</span><span class="p">).</span><span class="nf">filter</span><span class="p">(</span><span class="nb">Boolean</span><span class="p">)</span> <span class="o">??</span> <span class="p">[];</span>

<span class="kd">const</span> <span class="nx">corsOptions</span> <span class="o">=</span> <span class="p">{</span>
  <span class="na">origin</span><span class="p">:</span> <span class="p">(</span><span class="nx">origin</span><span class="p">,</span> <span class="nx">callback</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">origin</span><span class="p">)</span> <span class="p">{</span>
      <span class="c1">// Same-origin and curl/server-to-server have no Origin header.</span>
      <span class="c1">// Accept them — they are not browser cross-origin requests.</span>
      <span class="k">return</span> <span class="nf">callback</span><span class="p">(</span><span class="kc">null</span><span class="p">,</span> <span class="kc">true</span><span class="p">);</span>
    <span class="p">}</span>
    <span class="k">if </span><span class="p">(</span>
      <span class="nx">allowedOrigins</span><span class="p">.</span><span class="nf">includes</span><span class="p">(</span><span class="nx">origin</span><span class="p">)</span> <span class="o">||</span>
      <span class="nx">featureBranchOrigins</span><span class="p">.</span><span class="nf">includes</span><span class="p">(</span><span class="nx">origin</span><span class="p">)</span>
    <span class="p">)</span> <span class="p">{</span>
      <span class="k">return</span> <span class="nf">callback</span><span class="p">(</span><span class="kc">null</span><span class="p">,</span> <span class="kc">true</span><span class="p">);</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="nf">callback</span><span class="p">(</span><span class="kc">null</span><span class="p">,</span> <span class="kc">false</span><span class="p">);</span>  <span class="c1">// 403, not 500.</span>
  <span class="p">},</span>
  <span class="na">credentials</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="p">};</span>
</code></pre></div></div>

<p>Four properties on the allowlist by default. Feature branches plug in through an environment variable — the deployment pipeline appends the preview URL to <code class="language-plaintext highlighter-rouge">FEATURE_BRANCH_ORIGINS</code> when it provisions a branch deployment, and removes it when the branch is torn down.</p>

<p>Three things to note about the callback form:</p>

<ol>
  <li><strong><code class="language-plaintext highlighter-rouge">callback(null, false)</code> is the rejection path</strong> — the middleware returns a <code class="language-plaintext highlighter-rouge">403</code> instead of letting an exception bubble up to a <code class="language-plaintext highlighter-rouge">500</code>. The application no longer leaks the existence of the endpoint to non-allowlisted origins.</li>
  <li><strong>The empty-origin case is explicit.</strong> Curl and server-to-server callers do not send an Origin header. Treating them as allowed prevents internal jobs from breaking when the same API serves machine traffic.</li>
  <li><strong>The <code class="language-plaintext highlighter-rouge">credentials: true</code> flag is still on</strong> — that is correct <em>because</em> the allowlist is now strict.</li>
</ol>

<h2 id="what-we-had-to-fix-alongside-cors">What We Had To Fix Alongside CORS</h2>

<p>The middleware previously threw an Error on unknown origins:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Previously</span>
<span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">isAllowed</span><span class="p">)</span> <span class="k">throw</span> <span class="k">new</span> <span class="nc">Error</span><span class="p">(</span><span class="dl">'</span><span class="s1">Not allowed by CORS</span><span class="dl">'</span><span class="p">);</span>
</code></pre></div></div>

<p>Throwing inside the CORS middleware turns a "denied" outcome into a <code class="language-plaintext highlighter-rouge">500 Internal Server Error</code> from Express's default error handler. That tells the attacker the endpoint exists <em>and</em> is doing something interesting. The new callback path returns a clean <code class="language-plaintext highlighter-rouge">403</code>, and the error handler stays out of it.</p>

<h2 id="the-feature-branch-conversation">The Feature-Branch Conversation</h2>

<p>The frontend team's first response was the one we expect on every engagement that touches CORS:</p>

<blockquote>
  <p>"But we need dynamic subdomains for feature branches. We cannot maintain a list."</p>
</blockquote>

<p>The list is not maintained by hand — it is maintained by the deployment pipeline.</p>

<ul>
  <li>When a branch deployment is created, the pipeline appends its origin (e.g. <code class="language-plaintext highlighter-rouge">https://branch-1234.preview.client.com</code>) to <code class="language-plaintext highlighter-rouge">FEATURE_BRANCH_ORIGINS</code> in the API's environment.</li>
  <li>When the branch is torn down or merged, the pipeline removes it.</li>
  <li>The API restarts on env change. Cold start is sub-second; the redeploy is a non-event.</li>
</ul>

<p>The result is the same flexibility the regex used to give — any branch can talk to the API — without giving the same flexibility to anyone who can register <em>any</em> subdomain.</p>

<h2 id="verifying-after-the-change">Verifying After the Change</h2>

<p>The reproduction script we ran before the fix returns clean results afterwards:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 1. Legitimate origin — still works</span>
curl <span class="nt">-sI</span> <span class="s2">"</span><span class="nv">$API</span><span class="s2">/api/config"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Origin: https://app.client.com"</span> <span class="se">\</span>
  | <span class="nb">grep</span> <span class="nt">-i</span> access-control
<span class="c"># access-control-allow-origin: https://app.client.com</span>
<span class="c"># access-control-allow-credentials: true</span>

<span class="c"># 2. Unknown subdomain — no allow-origin header at all</span>
curl <span class="nt">-sI</span> <span class="s2">"</span><span class="nv">$API</span><span class="s2">/api/config"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Origin: https://evil.client.com"</span> <span class="se">\</span>
  | <span class="nb">grep</span> <span class="nt">-i</span> access-control
<span class="c"># (no output — header not set)</span>

<span class="c"># 3. Status code for blocked preflight — 403, not 500</span>
curl <span class="nt">-s</span> <span class="nt">-o</span> /dev/null <span class="nt">-w</span> <span class="s2">"%{http_code}</span><span class="se">\n</span><span class="s2">"</span> <span class="nt">-X</span> OPTIONS <span class="s2">"</span><span class="nv">$API</span><span class="s2">/api/config"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Origin: https://evil.client.com"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Access-Control-Request-Method: GET"</span>
<span class="c"># 403</span>
</code></pre></div></div>

<p>The browser, on the legitimate side, sees the same response as before. The browser, on the attacker side, sees a CORS error and never gets to the response body.</p>

<h2 id="what-we-added-alongside-cors--the-layered-story">What We Added Alongside CORS — The Layered Story</h2>

<p>CORS is a browser policy. A determined attacker can still hit the API directly without a browser. Three companion changes shipped alongside the allowlist:</p>

<ol>
  <li><strong>Allowed methods narrowed per endpoint.</strong> The middleware no longer advertises <code class="language-plaintext highlighter-rouge">DELETE</code> on endpoints that do not support it. Each route declares its own method set.</li>
  <li><strong>JWT validation on every authenticated request.</strong> A separate audience check — covered in <a href="/2026/how-a-jwt-audience-map-saved-a-cors-mistake-defense-in-depth-case-study/">its own case study</a> — runs alongside CORS. Even on the days CORS gets misconfigured again, the audience check holds.</li>
  <li><strong>Origin telemetry.</strong> We log every rejected origin to CloudWatch with a 30-day retention window. If a hostname starts showing up, the team sees it before it shows up in a support ticket.</li>
</ol>

<h2 id="five-things-we-now-check-on-every-cors-engagement">Five things we now check on every CORS engagement</h2>

<ol>
  <li><strong>Regex CORS allowlists are almost always wrong.</strong> The string form is more verbose but it is also unambiguous. Add a linter rule that fails the build on a non-string <code class="language-plaintext highlighter-rouge">cors.origin</code> value.</li>
  <li><strong>Wildcard subdomain regex matches subdomains you do not control.</strong> If you do not own every subdomain of your apex, the regex hands trust to whoever does.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">callback(null, false)</code> is the rejection path.</strong> Throwing inside CORS middleware turns a 403 into a 500 and tells the attacker something interesting happened.</li>
  <li><strong>Feature branches do not need wildcard CORS.</strong> A pipeline that updates an <code class="language-plaintext highlighter-rouge">FEATURE_BRANCH_ORIGINS</code> env var on create and tear-down delivers the same DX without the security gap.</li>
  <li><strong>Tighten <code class="language-plaintext highlighter-rouge">Access-Control-Allow-Methods</code> per route.</strong> A blanket method allowlist is an open menu of verbs for an attacker to probe. Routes declare the verbs they accept; the middleware reflects only those.</li>
</ol>

<aside class="post-cta">
  <h2 id="we-could-audit-your-cors">We Could Audit Your CORS</h2>

  <p>If you are running Express, Fastify, or Nest with a regex or wildcard CORS configuration — or if you have feature branches and you are not sure how they are being trusted — send us a note. <strong>Clearview Team</strong> has done this swap on a few stacks now. The same engineer who would run the check is the one who would write the patch, and that patch usually lands in the same sprint as the finding.</p>

  <p><a href="/work-with-us/">Scope an engagement →</a></p>
</aside>]]></content><author><name>Nedim Hadzimahmutovic</name></author><category term="express" /><category term="cors" /><category term="security" /><category term="devsecops" /><category term="api-security" /><category term="defense-in-depth" /><category term="web-api-security" /><category term="case-study" /><summary type="html"><![CDATA[A client was weeks from going live with their Express API. We had staging access and a brief to check every endpoint the public would be able to reach. The CORS configuration looked clean.]]></summary></entry><entry><title type="html">Cutting the File-Upload Bill: A Cognito + S3 Case Study</title><link href="https://blog.clearview.team/2026/cutting-the-file-upload-bill-cognito-s3-case-study/" rel="alternate" type="text/html" title="Cutting the File-Upload Bill: A Cognito + S3 Case Study" /><published>2026-08-24T11:00:00+02:00</published><updated>2026-08-24T11:00:00+02:00</updated><id>https://blog.clearview.team/2026/cutting-the-file-upload-bill-cognito-s3-case-study</id><content type="html" xml:base="https://blog.clearview.team/2026/cutting-the-file-upload-bill-cognito-s3-case-study/"><![CDATA[<p>The client had a managed file-upload service in their browser, the kind that gives you a hosted widget, signs every upload, runs a virus scan, and bills you per request. It worked, and the team liked the developer experience. The CFO did not like the invoice.</p>

<p>The obvious question was whether we could replace it with all native AWS services and keep the same browser-direct upload pattern. Yes, with a <strong>Cognito + S3</strong> setup. We built a three-layer permission model that most Cognito-on-S3 tutorials skip, which is why they leak.</p>

<p>Two AWS services do the work here, both worth one sentence each before we go further:</p>

<ul>
  <li><strong>S3</strong> — AWS's object-storage service. The bucket the files actually land in. Cheap, durable, billed per gigabyte stored and per request.</li>
  <li><strong>Cognito Identity Pool</strong> — AWS's way of handing a browser a short-lived AWS credential so it can talk to S3 directly, without an API server in the middle. The "boundary" the rest of this post tightens.</li>
</ul>

<h2 id="what-the-frontend-had-to-do">What the Frontend Had to Do</h2>

<p>The browser uploads files directly to storage — never through the backend API. This pattern matters for two reasons:</p>

<ol>
  <li><strong>Throughput.</strong> A 200 MB upload doesn't tie up a backend worker for two minutes.</li>
  <li><strong>Cost.</strong> Backend egress through the API doubles the bandwidth bill.</li>
</ol>

<p>The managed service handled this with a hosted SDK: the browser calls the service, gets a one-shot signed URL, uploads to the vendor's S3, and the vendor pushes the object into the client's bucket via a webhook. Clean DX. Big invoice.</p>

<p>What we needed to replicate:</p>

<ul>
  <li>Browser uploads directly to the client's S3 bucket</li>
  <li>No long-lived AWS credentials in the browser</li>
  <li>Per-user isolation (user A cannot read or overwrite user B's files)</li>
  <li>Content-type and size guards</li>
  <li>Anti-abuse layer for anonymous (pre-signup) uploads</li>
</ul>

<h2 id="the-architecture">The Architecture</h2>

<p>The pattern is well-known: <strong>AWS Cognito Identity Pool → temporary AWS credentials → direct PUT to S3</strong>. AWS Amplify ships this same architecture as <code class="language-plaintext highlighter-rouge">defineStorage</code>, which gives us a useful reference point.</p>

<p>The flow on a happy path:</p>

<p><img src="/assets/images/posts/cutting-the-file-upload-bill-cognito-s3-case-study/sequence.svg" alt="Sequence diagram showing the six-step happy path: the browser calls Cognito GetId and receives an IdentityId, then calls GetCredentialsForIdentity and receives temporary AWS credentials, then issues a scoped, content-type-checked PutObject directly to S3 and receives 200 OK." /></p>

<p>The whole thing lives in three Terraform files: <code class="language-plaintext highlighter-rouge">cognito.tf</code>, <code class="language-plaintext highlighter-rouge">s3.tf</code>, and <code class="language-plaintext highlighter-rouge">iam.tf</code>. The browser code is roughly thirty lines of <code class="language-plaintext highlighter-rouge">@aws-sdk/client-s3</code> and the standard Cognito credential provider.</p>

<p>The catch — and the reason most Cognito-on-S3 implementations leak — is that <strong>three independent layers</strong> decide whether a given operation is allowed. Get any one of them wrong and you've either bricked uploads or opened the bucket to the internet.</p>

<h2 id="the-three-layers-of-permission-evaluation">The Three Layers of Permission Evaluation</h2>

<p>This is the mental model we built before writing a single line of Terraform.</p>

<p><img src="/assets/images/posts/cutting-the-file-upload-bill-cognito-s3-case-study/three-layers.svg" alt="Three independent permission layers — session scope-down (AWS-managed, blocks S3 by default), IAM role policy (yours, content-type checks here), and S3 bucket policy (yours, the layer where the grant actually fires)." /></p>

<table>
  <thead>
    <tr>
      <th>Layer</th>
      <th>What it controls</th>
      <th>Where it lives</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Session scope-down policy</strong></td>
      <td>Which AWS services unauthenticated credentials can call at all</td>
      <td><code class="language-plaintext highlighter-rouge">AmazonCognitoUnAuthedIdentitiesSessionPolicy</code> (AWS-managed)</td>
    </tr>
    <tr>
      <td><strong>IAM role policy</strong></td>
      <td>What actions the role can take on which resources</td>
      <td>Attached to the role you create</td>
    </tr>
    <tr>
      <td><strong>Bucket policy</strong></td>
      <td>What principals can do directly to the bucket</td>
      <td>Attached to the S3 bucket itself</td>
    </tr>
  </tbody>
</table>

<p>The non-obvious behavior:</p>

<ul>
  <li>The <strong>session scope-down</strong> for unauthenticated identities does <strong>not</strong> include S3. So an IAM grant of <code class="language-plaintext highlighter-rouge">s3:PutObject</code> to the unauthenticated role is silently blocked by the session policy. This trips up everyone the first time.</li>
  <li><strong>Bucket policies bypass the session scope-down.</strong> A grant of <code class="language-plaintext highlighter-rouge">s3:PutObject</code> to the Cognito role principal <em>inside the bucket policy</em> is honored.</li>
  <li>Therefore: <strong>put your real grants in the bucket policy, not the IAM policy</strong> (or grant via both for defense in depth — only the bucket-policy grant will actually fire for unauthenticated identities).</li>
</ul>

<p>Print it out.</p>

<h2 id="the-terraform--identity-pool">The Terraform — Identity Pool</h2>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">resource</span> <span class="s2">"aws_cognito_identity_pool"</span> <span class="s2">"uploads"</span> <span class="p">{</span>
  <span class="nx">identity_pool_name</span>               <span class="o">=</span> <span class="s2">"client-uploads"</span>
  <span class="nx">allow_unauthenticated_identities</span> <span class="o">=</span> <span class="kc">true</span>
<span class="p">}</span>

<span class="nx">resource</span> <span class="s2">"aws_cognito_identity_pool_roles_attachment"</span> <span class="s2">"uploads"</span> <span class="p">{</span>
  <span class="nx">identity_pool_id</span> <span class="o">=</span> <span class="nx">aws_cognito_identity_pool</span><span class="p">.</span><span class="nx">uploads</span><span class="p">.</span><span class="nx">id</span>
  <span class="nx">roles</span> <span class="o">=</span> <span class="p">{</span>
    <span class="s2">"unauthenticated"</span> <span class="p">=</span> <span class="nx">aws_iam_role</span><span class="p">.</span><span class="nx">cognito_unauth</span><span class="p">.</span><span class="nx">arn</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>We <em>do</em> allow unauthenticated identities, because the client's app needs to accept uploads before signup completes. The risk is real, and the rest of the Terraform is how we tightened around it.</p>

<h2 id="the-terraform--iam-role">The Terraform — IAM Role</h2>

<p>The IAM role for the unauthenticated identity gets a minimal trust policy and a permission policy that grants only <code class="language-plaintext highlighter-rouge">s3:PutObject</code> to a per-identity prefix. Note: this is a defense-in-depth layer; the actual grant that fires for unauthenticated identities will be the bucket policy below.</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">data</span> <span class="s2">"aws_iam_policy_document"</span> <span class="s2">"cognito_unauth_assume"</span> <span class="p">{</span>
  <span class="nx">statement</span> <span class="p">{</span>
    <span class="nx">actions</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"sts:AssumeRoleWithWebIdentity"</span><span class="p">]</span>
    <span class="nx">principals</span> <span class="p">{</span>
      <span class="nx">type</span>        <span class="o">=</span> <span class="s2">"Federated"</span>
      <span class="nx">identifiers</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"cognito-identity.amazonaws.com"</span><span class="p">]</span>
    <span class="p">}</span>
    <span class="nx">condition</span> <span class="p">{</span>
      <span class="nx">test</span>     <span class="o">=</span> <span class="s2">"StringEquals"</span>
      <span class="nx">variable</span> <span class="o">=</span> <span class="s2">"cognito-identity.amazonaws.com:aud"</span>
      <span class="nx">values</span>   <span class="o">=</span> <span class="p">[</span><span class="nx">aws_cognito_identity_pool</span><span class="p">.</span><span class="nx">uploads</span><span class="p">.</span><span class="nx">id</span><span class="p">]</span>
    <span class="p">}</span>
    <span class="nx">condition</span> <span class="p">{</span>
      <span class="nx">test</span>     <span class="o">=</span> <span class="s2">"ForAnyValue:StringLike"</span>
      <span class="nx">variable</span> <span class="o">=</span> <span class="s2">"cognito-identity.amazonaws.com:amr"</span>
      <span class="nx">values</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">"unauthenticated"</span><span class="p">]</span>
    <span class="p">}</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="nx">resource</span> <span class="s2">"aws_iam_role"</span> <span class="s2">"cognito_unauth"</span> <span class="p">{</span>
  <span class="nx">name</span>               <span class="o">=</span> <span class="s2">"cognito-uploads-unauth"</span>
  <span class="nx">assume_role_policy</span> <span class="o">=</span> <span class="nx">data</span><span class="p">.</span><span class="nx">aws_iam_policy_document</span><span class="p">.</span><span class="nx">cognito_unauth_assume</span><span class="p">.</span><span class="nx">json</span>
<span class="p">}</span>

<span class="nx">data</span> <span class="s2">"aws_iam_policy_document"</span> <span class="s2">"cognito_unauth_perms"</span> <span class="p">{</span>
  <span class="nx">statement</span> <span class="p">{</span>
    <span class="nx">actions</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">"s3:PutObject"</span><span class="p">,</span> <span class="s2">"s3:AbortMultipartUpload"</span><span class="p">,</span> <span class="s2">"s3:ListMultipartUploadParts"</span><span class="p">]</span>
    <span class="nx">resources</span> <span class="o">=</span> <span class="p">[</span>
      <span class="s2">"${aws_s3_bucket.uploads.arn}/uploads/$${cognito-identity.amazonaws.com:sub}/*"</span><span class="p">,</span>
      <span class="s2">"${aws_s3_bucket.uploads.arn}/documents/$${cognito-identity.amazonaws.com:sub}/*"</span><span class="p">,</span>
    <span class="p">]</span>
    <span class="nx">condition</span> <span class="p">{</span>
      <span class="nx">test</span>     <span class="o">=</span> <span class="s2">"StringLike"</span>
      <span class="nx">variable</span> <span class="o">=</span> <span class="s2">"s3:content-type"</span>
      <span class="nx">values</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">"image/*"</span><span class="p">,</span> <span class="s2">"video/*"</span><span class="p">,</span> <span class="s2">"application/pdf"</span><span class="p">]</span>
    <span class="p">}</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="nx">resource</span> <span class="s2">"aws_iam_role_policy"</span> <span class="s2">"cognito_unauth"</span> <span class="p">{</span>
  <span class="nx">role</span>   <span class="o">=</span> <span class="nx">aws_iam_role</span><span class="p">.</span><span class="nx">cognito_unauth</span><span class="p">.</span><span class="nx">id</span>
  <span class="nx">policy</span> <span class="o">=</span> <span class="nx">data</span><span class="p">.</span><span class="nx">aws_iam_policy_document</span><span class="p">.</span><span class="nx">cognito_unauth_perms</span><span class="p">.</span><span class="nx">json</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Three constraints, all important:</p>

<ol>
  <li><strong><code class="language-plaintext highlighter-rouge">s3:PutObject</code> only.</strong> No <code class="language-plaintext highlighter-rouge">GetObject</code>, no <code class="language-plaintext highlighter-rouge">DeleteObject</code>, no <code class="language-plaintext highlighter-rouge">ListBucket</code>. The browser writes; it does not read or enumerate.</li>
  <li><strong>Per-identity prefix via <code class="language-plaintext highlighter-rouge">${cognito-identity.amazonaws.com:sub}</code>.</strong> AWS interpolates this at request time. User A's credentials can only write to <code class="language-plaintext highlighter-rouge">uploads/&lt;A's-sub-id&gt;/*</code>. They cannot guess their way into User B's folder. Terraform's <code class="language-plaintext highlighter-rouge">$$</code> is the escape for AWS template syntax — easy to miss the first time.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">s3:content-type</code> allowlist.</strong> The browser declares the content type; AWS checks it before accepting the PUT. <code class="language-plaintext highlighter-rouge">text/html</code> is denied at the IAM boundary. (Note: <code class="language-plaintext highlighter-rouge">s3:content-type</code> works in IAM policies but <strong>not</strong> in bucket policies — keep this one here.)</li>
</ol>

<h2 id="the-terraform--bucket-and-bucket-policy">The Terraform — Bucket and Bucket Policy</h2>

<p>Because the session scope-down blocks the IAM grant for unauthenticated identities, the actual <code class="language-plaintext highlighter-rouge">PutObject</code> allow has to live in the bucket policy:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">resource</span> <span class="s2">"aws_s3_bucket"</span> <span class="s2">"uploads"</span> <span class="p">{</span>
  <span class="nx">bucket</span> <span class="o">=</span> <span class="s2">"client-uploads-prod"</span>
<span class="p">}</span>

<span class="nx">resource</span> <span class="s2">"aws_s3_bucket_public_access_block"</span> <span class="s2">"uploads"</span> <span class="p">{</span>
  <span class="nx">bucket</span>                  <span class="o">=</span> <span class="nx">aws_s3_bucket</span><span class="p">.</span><span class="nx">uploads</span><span class="p">.</span><span class="nx">id</span>
  <span class="nx">block_public_acls</span>       <span class="o">=</span> <span class="kc">true</span>
  <span class="nx">block_public_policy</span>     <span class="o">=</span> <span class="kc">true</span>
  <span class="nx">ignore_public_acls</span>      <span class="o">=</span> <span class="kc">true</span>
  <span class="nx">restrict_public_buckets</span> <span class="o">=</span> <span class="kc">true</span>
<span class="p">}</span>

<span class="nx">resource</span> <span class="s2">"aws_s3_bucket_versioning"</span> <span class="s2">"uploads"</span> <span class="p">{</span>
  <span class="nx">bucket</span> <span class="o">=</span> <span class="nx">aws_s3_bucket</span><span class="p">.</span><span class="nx">uploads</span><span class="p">.</span><span class="nx">id</span>
  <span class="nx">versioning_configuration</span> <span class="p">{</span> <span class="nx">status</span> <span class="o">=</span> <span class="s2">"Enabled"</span> <span class="p">}</span>
<span class="p">}</span>

<span class="nx">data</span> <span class="s2">"aws_iam_policy_document"</span> <span class="s2">"uploads_bucket_policy"</span> <span class="p">{</span>
  <span class="c1"># The grant that actually fires for unauthenticated Cognito identities.</span>
  <span class="nx">statement</span> <span class="p">{</span>
    <span class="nx">sid</span>       <span class="o">=</span> <span class="s2">"AllowScopedUploads"</span>
    <span class="nx">effect</span>    <span class="o">=</span> <span class="s2">"Allow"</span>
    <span class="nx">actions</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">"s3:PutObject"</span><span class="p">,</span> <span class="s2">"s3:AbortMultipartUpload"</span><span class="p">]</span>
    <span class="nx">principals</span> <span class="p">{</span>
      <span class="nx">type</span>        <span class="o">=</span> <span class="s2">"AWS"</span>
      <span class="nx">identifiers</span> <span class="o">=</span> <span class="p">[</span><span class="nx">aws_iam_role</span><span class="p">.</span><span class="nx">cognito_unauth</span><span class="p">.</span><span class="nx">arn</span><span class="p">]</span>
    <span class="p">}</span>
    <span class="nx">resources</span> <span class="o">=</span> <span class="p">[</span>
      <span class="s2">"${aws_s3_bucket.uploads.arn}/uploads/$${cognito-identity.amazonaws.com:sub}/*"</span><span class="p">,</span>
      <span class="s2">"${aws_s3_bucket.uploads.arn}/documents/$${cognito-identity.amazonaws.com:sub}/*"</span><span class="p">,</span>
    <span class="p">]</span>
  <span class="p">}</span>

  <span class="c1"># Deny everything else, explicitly, for the Cognito role.</span>
  <span class="nx">statement</span> <span class="p">{</span>
    <span class="nx">sid</span>       <span class="o">=</span> <span class="s2">"DenyEverythingElse"</span>
    <span class="nx">effect</span>    <span class="o">=</span> <span class="s2">"Deny"</span>
    <span class="nx">actions</span> <span class="o">=</span> <span class="p">[</span>
      <span class="s2">"s3:GetObject"</span><span class="p">,</span> <span class="s2">"s3:DeleteObject"</span><span class="p">,</span> <span class="s2">"s3:ListBucket"</span><span class="p">,</span>
      <span class="s2">"s3:ListBucketVersions"</span><span class="p">,</span> <span class="s2">"s3:ListBucketMultipartUploads"</span><span class="p">,</span>
    <span class="p">]</span>
    <span class="nx">principals</span> <span class="p">{</span>
      <span class="nx">type</span>        <span class="o">=</span> <span class="s2">"AWS"</span>
      <span class="nx">identifiers</span> <span class="o">=</span> <span class="p">[</span><span class="nx">aws_iam_role</span><span class="p">.</span><span class="nx">cognito_unauth</span><span class="p">.</span><span class="nx">arn</span><span class="p">]</span>
    <span class="p">}</span>
    <span class="nx">resources</span> <span class="o">=</span> <span class="p">[</span>
      <span class="nx">aws_s3_bucket</span><span class="p">.</span><span class="nx">uploads</span><span class="p">.</span><span class="nx">arn</span><span class="p">,</span>
      <span class="s2">"${aws_s3_bucket.uploads.arn}/*"</span><span class="p">,</span>
    <span class="p">]</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="nx">resource</span> <span class="s2">"aws_s3_bucket_policy"</span> <span class="s2">"uploads"</span> <span class="p">{</span>
  <span class="nx">bucket</span> <span class="o">=</span> <span class="nx">aws_s3_bucket</span><span class="p">.</span><span class="nx">uploads</span><span class="p">.</span><span class="nx">id</span>
  <span class="nx">policy</span> <span class="o">=</span> <span class="nx">data</span><span class="p">.</span><span class="nx">aws_iam_policy_document</span><span class="p">.</span><span class="nx">uploads_bucket_policy</span><span class="p">.</span><span class="nx">json</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">block_public_acls</code> plus <code class="language-plaintext highlighter-rouge">block_public_policy</code> mean the bucket can never accidentally drift to "world-readable" — even if a future engineer adds a <code class="language-plaintext highlighter-rouge">Principal: *</code> statement, AWS rejects the policy at apply time.</p>

<aside class="callout">
  <p><strong><code class="language-plaintext highlighter-rouge">s3:content-type</code> is IAM-only.</strong> It is a valid condition key in IAM policies but <strong>not</strong> in bucket policies. AWS will reject a bucket policy that tries to use it with <code class="language-plaintext highlighter-rouge">MalformedPolicy</code>. The IAM-side check is the one that fires when temporary credentials present a <code class="language-plaintext highlighter-rouge">Content-Type</code> header to S3. For Cognito unauthenticated identities, the IAM grant is otherwise blocked by the session scope-down — so this check is doing useful work for <em>authenticated</em> identities, and is a defense-in-depth layer if you later move to the basic auth flow.</p>
</aside>

<h2 id="verifying-with-curl">Verifying With <code class="language-plaintext highlighter-rouge">curl</code></h2>

<p>Before handing off, we validated the boundary the same way an attacker would — with <code class="language-plaintext highlighter-rouge">curl</code> and no SDK in between.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 1. Get an anonymous identity.</span>
curl <span class="nt">-s</span> <span class="nt">-X</span> POST <span class="s2">"https://cognito-identity.us-east-1.amazonaws.com/"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Content-Type: application/x-amz-json-1.1"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"X-Amz-Target: AWSCognitoIdentityService.GetId"</span> <span class="se">\</span>
  <span class="nt">-d</span> <span class="s1">'{"IdentityPoolId":"us-east-1:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"}'</span>

<span class="c"># 2. Exchange it for temporary AWS credentials.</span>
curl <span class="nt">-s</span> <span class="nt">-X</span> POST <span class="s2">"https://cognito-identity.us-east-1.amazonaws.com/"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Content-Type: application/x-amz-json-1.1"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"X-Amz-Target: AWSCognitoIdentityService.GetCredentialsForIdentity"</span> <span class="se">\</span>
  <span class="nt">-d</span> <span class="s1">'{"IdentityId":"us-east-1:yyyyyyyy-..."}'</span>
</code></pre></div></div>

<p>We then exported the credentials and ran the matrix of operations our policy is supposed to block:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Reads — denied</span>
aws s3 <span class="nb">cp </span>s3://client-uploads-prod/uploads/foo/bar.jpg /tmp/x
<span class="c"># An error occurred (AccessDenied) ...</span>

<span class="c"># Listing — denied</span>
aws s3 <span class="nb">ls </span>s3://client-uploads-prod/
<span class="c"># An error occurred (AccessDenied) ...</span>

<span class="c"># Deletes — denied</span>
aws s3 <span class="nb">rm </span>s3://client-uploads-prod/uploads/foo/bar.jpg
<span class="c"># An error occurred (AccessDenied) ...</span>

<span class="c"># Writing outside your own prefix — denied</span>
<span class="nb">echo</span> <span class="s2">"x"</span> | aws s3 <span class="nb">cp</span> - s3://client-uploads-prod/uploads/somebody-else/file.txt
<span class="c"># An error occurred (AccessDenied) ...</span>

<span class="c"># Writing wrong content type to your own prefix — denied</span>
aws s3 <span class="nb">cp </span>evil.html s3://client-uploads-prod/uploads/<span class="nv">$MY_SUB</span>/evil.html <span class="se">\</span>
  <span class="nt">--content-type</span> <span class="s2">"text/html"</span>
<span class="c"># An error occurred (AccessDenied) ...</span>

<span class="c"># Writing the right content type to your own prefix — allowed</span>
aws s3 <span class="nb">cp </span>ok.jpg s3://client-uploads-prod/uploads/<span class="nv">$MY_SUB</span>/ok.jpg
<span class="c"># upload: ./ok.jpg to s3://client-uploads-prod/...</span>
</code></pre></div></div>

<p>Every line in that matrix is part of the acceptance test. We re-run it in CI against staging on every Terraform change.</p>

<h2 id="what-we-added-back-from-the-managed-service">What We Added Back From the Managed Service</h2>

<p>Replacing a managed service means rebuilding the safety net the vendor wrapped around the happy path. Three pieces in particular needed deliberate replacement.</p>

<h3 id="1-file-size-limits-and-content-type-validation-properly">1. File-size limits and content-type validation, properly</h3>

<p>The IAM-level <code class="language-plaintext highlighter-rouge">s3:content-type</code> check trusts the header — easy to spoof. We added a Lambda triggered by <code class="language-plaintext highlighter-rouge">s3:ObjectCreated:*</code> that:</p>

<ul>
  <li>Reads the first 16 bytes of the new object (<code class="language-plaintext highlighter-rouge">magic bytes</code>)</li>
  <li>Matches against an allowlist of file signatures (JPEG, PNG, MP4, PDF…)</li>
  <li>Deletes the object and writes a <code class="language-plaintext highlighter-rouge">denied/&lt;sub&gt;/&lt;timestamp&gt;.log</code> audit entry if the magic bytes do not match the prefix policy</li>
</ul>

<p>This is the same defense-in-depth pattern the managed service used; we now own it.</p>

<h3 id="2-anti-abuse-for-guest-uploads">2. Anti-abuse for guest uploads</h3>

<p>For genuinely anonymous flows (pre-signup), we put a small backend endpoint in front. The browser proves it is a human with reCAPTCHA, the backend validates the token, and only then does the frontend get to call <code class="language-plaintext highlighter-rouge">GetCredentialsForIdentity</code>. This converts "anyone can get AWS credentials" into "any verified human can get AWS credentials" — the bar we wanted.</p>

<h3 id="3-bucket-abuse-limits">3. Bucket abuse limits</h3>

<p>We layered in two things the managed service used to do for us:</p>

<ul>
  <li><strong>S3 Lifecycle rule</strong> that deletes anything in <code class="language-plaintext highlighter-rouge">uploads/$sub/</code> older than 30 days unless the object has been promoted (a tag set when the user completes signup).</li>
  <li><strong>CloudWatch alarm</strong> on <code class="language-plaintext highlighter-rouge">BucketSizeBytes</code> and on <code class="language-plaintext highlighter-rouge">NumberOfObjects</code> per prefix, with thresholds set at 5× the 30-day rolling average. A bored teenager filling the bucket with zeros sets off the alarm before it sets off the invoice.</li>
</ul>

<h2 id="migration-plan-no-downtime">Migration Plan (No Downtime)</h2>

<p>We rolled this out in three phases over two sprints. The client never lost an upload.</p>

<ol>
  <li><strong>Dual-write.</strong> Frontend hits the managed service <em>and</em> the new Cognito flow in parallel. Compare file hashes in the backend. Surface any mismatch in a Slack channel.</li>
  <li><strong>Read switch.</strong> Once dual-write was clean for two weeks, the application started reading from S3. The managed service's pipeline stayed running, but downstream consumers stopped touching it.</li>
  <li><strong>Cutover.</strong> Frontend disables the managed-service call. Vendor account moved to "evergreen" tier. After 60 days of clean operation we cancelled the contract.</li>
</ol>

<h2 id="what-the-client-walked-away-with">What the Client Walked Away With</h2>

<p>In rough numbers (exact figures live in the client's invoice records, not ours):</p>

<ul>
  <li><strong>The monthly bill dropped sharply.</strong> Most of the savings came from the dominant cost shifting from "you pay per upload" to "you pay per gigabyte stored" — and on this workload, per-GB storage on S3 is a fraction of what the managed service was charging per request.</li>
  <li><strong>They own the pipeline now.</strong> The whole upload pipeline lives in their own Terraform repository. If they ever want to swap a piece out, they can.</li>
  <li><strong>They can do things the managed service did not let them do.</strong> Their backend can now sign upload URLs based on any business rule they care about — has the user finished signup, are they on a paid plan, what country are they in. None of that was possible before.</li>
  <li><strong>The boundary is tighter than the tutorial version.</strong> Per-user prefixes, magic-byte validation, abuse alarms, versioning so we can undo a bad upload, CloudTrail data events on every S3 operation. The kind of plumbing that gets noticed only when something tries to abuse it.</li>
</ul>

<p>The client's reaction, paraphrased: <em>"This is the architecture we wished the vendor's docs had explained in the first place."</em></p>

<h2 id="two-gotchas-the-tutorials-skip">Two gotchas the tutorials skip</h2>

<p>First, <code class="language-plaintext highlighter-rouge">s3:prefix</code> only applies to <code class="language-plaintext highlighter-rouge">s3:ListBucket</code>, not to <code class="language-plaintext highlighter-rouge">s3:ListBucketMultipartUploads</code> — split them into separate statements or AWS rejects the policy at apply time. Second, SDK helpers like <code class="language-plaintext highlighter-rouge">--dryrun</code> check client-side logic only; they do not call AWS IAM. Test the deny path with <code class="language-plaintext highlighter-rouge">curl</code> and the AWS CLI directly, or the acceptance test is lying to you.</p>

<p>Two things I would enable from day one on the next build: versioning on the bucket, so a bad upload is one command away from being undone, and CloudTrail data events, so the forensic answer to "what did the temporary credential actually do" is available at all.</p>

<aside class="post-cta">
  <h2 id="we-could-run-this-migration-for-you">We Could Run This Migration For You</h2>

  <p>If you are running a managed upload service and the bill is starting to bite — or you are considering one and the vendor lock-in worries you — we have done this swap before and we can do it again. <strong>Clearview Team</strong> writes the Terraform (roughly 200 lines once the production scaffolding is in), runs the migration in two sprints with zero downtime, and stays on for the next sprint of pen-test work so the new boundary actually holds.</p>

  <p><a href="mailto:info@clearview.team?subject=Cognito%20S3%20upload%20migration%20enquiry">Scope an engagement →</a></p>
</aside>]]></content><author><name>Nedim Hadzimahmutovic</name></author><category term="aws" /><category term="cognito" /><category term="s3" /><category term="terraform" /><category term="devops" /><category term="file-uploads" /><category term="cloud-security" /><category term="web-api-security" /><category term="aws-cost-optimization" /><category term="aws-devops" /><category term="case-study" /><summary type="html"><![CDATA[The client had a managed file-upload service in their browser, the kind that gives you a hosted widget, signs every upload, runs a virus scan, and bills you per request. It worked, and the team liked the developer experience. The CFO did not like the invoice.]]></summary></entry><entry><title type="html">Upgrading TypeORM 0.3 to 1.0 in Production: A NestJS Case Study</title><link href="https://blog.clearview.team/2026/the-typeorm-1-0-upgrade-that-did-not-block-production/" rel="alternate" type="text/html" title="Upgrading TypeORM 0.3 to 1.0 in Production: A NestJS Case Study" /><published>2026-08-17T11:00:00+02:00</published><updated>2026-08-17T11:00:00+02:00</updated><id>https://blog.clearview.team/2026/the-typeorm-1-0-upgrade-that-did-not-block-production</id><content type="html" xml:base="https://blog.clearview.team/2026/the-typeorm-1-0-upgrade-that-did-not-block-production/"><![CDATA[<p>A backend service I work on runs on TypeORM. Earlier this year we jumped from 0.x to the new 1.0 release. The old line had gone almost five years without a breaking change, and a class of quietly-wrong-answer bugs had piled up in it: queries that returned bad data instead of raising an error. The 1.0 release fixes them by making the bad queries fail loudly instead. It also forced an <code class="language-plaintext highlighter-rouge">@nestjs/typeorm</code> upgrade at the same time, since the NestJS glue layer for TypeORM crashes at startup on the new TypeORM if you are still on the old version. But the catch with any major ORM upgrade is that every entity, every query builder, every database factory in the test suite gets touched.</p>

<p>The team had two constraints. <em>"Do not block the rest of the work"</em> and <em>"do not break production."</em> On a service that deploys every day, with a long backlog of feature work that the product team had committed to, an upgrade that needed the whole team to stop and migrate together would have been the wrong shape. We had to find the order that let the migration happen alongside the feature work, with the test suite green at every commit and production unaware that anything had changed.</p>

<p>The order we ended up with: a staging branch that absorbed the breaking changes, the test-harness migration ahead of the runtime, the Docker image pinned in parallel, and the eight or so smaller follow-ups that landed after the main upgrade so nobody had to read a five-thousand-line PR.</p>

<h2 id="two-terms-before-we-go-further">Two terms before we go further</h2>

<ul>
  <li><strong>TypeORM</strong> is a TypeScript ORM for SQL databases. It maps decorated TypeScript classes to tables and gives you a query builder, an entity manager, and a migration runner. The 0.x to 1.0 upgrade is the kind of release that touches every file in the codebase that talks to the database.</li>
  <li><strong>Test harness</strong>, in this post, is the infrastructure around the test suite: the factories that build entities, the per-test database setup and teardown, the mocked services, the rate-limit mocks, the fixtures. None of it ships to production. All of it has to be green for CI to pass.</li>
</ul>

<h2 id="four-places-the-upgrade-broke">Four places the upgrade broke</h2>

<p>The breaking surface on the upgrade landed in four places.</p>

<p><strong>Factory APIs.</strong> TypeORM's <code class="language-plaintext highlighter-rouge">setSeederFactory</code> shape changed. Every factory we had (about thirty of them, one per major entity) needed a new signature.</p>

<p><strong>Repository methods.</strong> A handful of methods that had a <em>"first one wins"</em> behaviour in 0.x became <em>"explicit single or throw"</em> in 1.0. <code class="language-plaintext highlighter-rouge">findOne()</code> without an options argument is no longer valid; you have to pass <code class="language-plaintext highlighter-rouge">findOne({ where: { id } })</code> or use <code class="language-plaintext highlighter-rouge">findOneBy({ id })</code>. This was a global codemod.</p>

<p><strong>Entity manager transactions.</strong> The transaction API tightened. Some callsites that had been relying on implicit transaction propagation needed explicit <code class="language-plaintext highlighter-rouge">manager.withRepository(...)</code> calls.</p>

<p><strong>Decorator metadata.</strong> <code class="language-plaintext highlighter-rouge">@PrimaryGeneratedColumn</code> got stricter about its options. A handful of entities with custom configurations needed a small rewrite.</p>

<p>None of those were hard individually. All four at once, on a codebase with hundreds of entities, was the upgrade.</p>

<h2 id="the-order-we-ran-it-in">The order we ran it in</h2>

<aside class="callout">
  <p><strong>The rule that ordered everything else.</strong> If the test harness is broken, every open PR is blocked. If the runtime is broken, only the upgrade PR is blocked. Test harness goes first.</p>
</aside>

<figure>
  <img src="/assets/images/posts/the-typeorm-1-0-upgrade-that-did-not-block-production/staging-timeline.svg" alt="A three-lane timeline across two weeks. Lane 1: the Docker base image pinned to node:22.17.1 on a parallel branch in week 0, then left inert on the staging branch through weeks 1 and 2. Lane 2: the test-harness migration (setSeederFactory shape change, about thirty factories) landed in week 1 while the runtime was still on 0.x. Lane 3: the runtime upgrade (typeorm@1.0.0, findOne to findOneBy codemod, transaction-API tightening) landed on the staging branch in week 1, absorbed daily feature-PR rebases across week 2, and merged to main at the end of week 2." loading="lazy" width="1600" height="720" />
  <figcaption>Three pieces of work, three landing windows, one merge to main. The Docker base spent a week proving itself inert, the test harness landed while the runtime was still on 0.x, and the runtime upgrade lived on a staging branch long enough for every feature PR to rebase onto it.</figcaption>
</figure>

<p>Each step below has its own commit (or branch) and was reviewable on its own.</p>

<p><strong>One. Pin the new Docker image and Node version on a parallel branch.</strong></p>

<p>TypeORM 1.0 requires Node 22. Our production image was on Node 20. Before any code changed, the new image had to be built and tested. We landed <code class="language-plaintext highlighter-rouge">Node 22.17.1 + production stage yarn setup for typeorm@1.0.0</code> as the first commit on the upgrade branch:</p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Dockerfile (shortened)</span>
<span class="k">FROM</span><span class="w"> </span><span class="s">node:22.17.1-bookworm-slim</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">builder</span>
<span class="k">WORKDIR</span><span class="s"> /app</span>
<span class="k">COPY</span><span class="s"> package.json yarn.lock .yarnrc.yml ./</span>
<span class="k">COPY</span><span class="s"> .yarn .yarn</span>
<span class="k">RUN </span>yarn <span class="nb">install</span> <span class="nt">--immutable</span>

<span class="k">FROM</span><span class="w"> </span><span class="s">node:22.17.1-bookworm-slim</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">production</span>
<span class="k">WORKDIR</span><span class="s"> /app</span>
<span class="k">COPY</span><span class="s"> --from=builder /app/node_modules ./node_modules</span>
<span class="k">COPY</span><span class="s"> . .</span>
<span class="k">RUN </span>yarn <span class="nb">install</span> <span class="nt">--immutable</span> <span class="nt">--production</span>
<span class="k">CMD</span><span class="s"> ["node", "dist/server.js"]</span>
</code></pre></div></div>

<p>The build target uses the production-stage yarn setup because the new TypeORM brings transitive dependencies that the old <code class="language-plaintext highlighter-rouge">--immutable</code> flag was tolerant of and the new one is not. We ran the new image against the old code for a week in staging to make sure the runtime change itself was inert.</p>

<p><strong>Two. Migrate the test harness <em>before</em> the runtime.</strong></p>

<p>This is the step that decided whether the upgrade would block the team or not. The test harness is what runs in CI on <em>every</em> PR. If the test harness is half-migrated, nobody can ship anything. So the test harness goes first, in its own PR, with the old TypeORM still in production:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// database/factories/address.factory.ts (before)</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">define</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">typeorm-seeding</span><span class="dl">"</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">Address</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">../../modules/address/address.entity</span><span class="dl">"</span><span class="p">;</span>

<span class="nf">define</span><span class="p">(</span><span class="nx">Address</span><span class="p">,</span> <span class="p">(</span><span class="nx">faker</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">address</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">Address</span><span class="p">();</span>
  <span class="nx">address</span><span class="p">.</span><span class="nx">street</span> <span class="o">=</span> <span class="nx">faker</span><span class="p">.</span><span class="nx">location</span><span class="p">.</span><span class="nf">street</span><span class="p">();</span>
  <span class="nx">address</span><span class="p">.</span><span class="nx">city</span> <span class="o">=</span> <span class="nx">faker</span><span class="p">.</span><span class="nx">location</span><span class="p">.</span><span class="nf">city</span><span class="p">();</span>
  <span class="k">return</span> <span class="nx">address</span><span class="p">;</span>
<span class="p">});</span>

<span class="c1">// database/factories/address.factory.ts (after)</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">setSeederFactory</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">typeorm-extension</span><span class="dl">"</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">Address</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">../../modules/address/address.entity</span><span class="dl">"</span><span class="p">;</span>

<span class="k">export</span> <span class="k">default</span> <span class="nf">setSeederFactory</span><span class="p">(</span><span class="nx">Address</span><span class="p">,</span> <span class="p">(</span><span class="nx">faker</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">address</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">Address</span><span class="p">();</span>
  <span class="nx">address</span><span class="p">.</span><span class="nx">street</span> <span class="o">=</span> <span class="nx">faker</span><span class="p">.</span><span class="nx">location</span><span class="p">.</span><span class="nf">street</span><span class="p">();</span>
  <span class="nx">address</span><span class="p">.</span><span class="nx">city</span> <span class="o">=</span> <span class="nx">faker</span><span class="p">.</span><span class="nx">location</span><span class="p">.</span><span class="nf">city</span><span class="p">();</span>
  <span class="k">return</span> <span class="nx">address</span><span class="p">;</span>
<span class="p">});</span>
</code></pre></div></div>

<p>About thirty factories, all the same shape, all migrated in one commit. We landed the test harness work as a series of <em>"complete TypeORM 1.0 test-harness migration"</em> commits. Runtime was still on 0.x, test setup was ready for 1.0. The two coexisted because the factories are dev-time only.</p>

<p><strong>Three. Bump the runtime on a staging branch.</strong></p>

<p>The actual <code class="language-plaintext highlighter-rouge">package.json</code> change. Roughly fifteen lines changed in the manifest, a hundred or so callsites changed across the source for the <code class="language-plaintext highlighter-rouge">findOne</code> → <code class="language-plaintext highlighter-rouge">findOneBy</code> codemod and the transaction-API tightening. We landed it on a staging branch that lived for about a week.</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"dependencies"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"typeorm"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^1.0.0"</span><span class="p">,</span><span class="w">
    </span><span class="err">//</span><span class="w"> </span><span class="err">...</span><span class="w"> </span><span class="err">transitive</span><span class="w"> </span><span class="err">updates</span><span class="w"> </span><span class="err">for</span><span class="w"> </span><span class="err">things</span><span class="w"> </span><span class="err">that</span><span class="w"> </span><span class="err">wanted</span><span class="w"> </span><span class="err">Node</span><span class="w"> </span><span class="mi">22</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>During that week, every other PR rebased onto the staging branch as it was merged. Conflicts happened (entity decorators and query builders both got touched), but they stayed bounded. We had a Slack channel for <em>"my PR is breaking on the staging rebase, who else has touched this file"</em> and it never had more than one or two messages a day.</p>

<p><strong>Four. Fix the small tests that quietly broke.</strong></p>

<p>Once the runtime was on 1.0, a small set of tests broke for reasons the code diff itself did not show. The framework was behaving slightly differently now, and the tests were catching it. A rate-limit mock had assumed the old library called it at one specific moment; the new library called it a moment later. A recaptcha mock had relied on a method name that had been renamed. Some test suites we had not touched in a long time were quietly assuming old data-object shapes and stopped compiling once the underlying types changed.</p>

<p>We landed these as a series of small commits with names like <em>"test: align auth assertions and complete rate-limit mock"</em> and <em>"test: fix constructor drift in the compile-failing test suites."</em> Each one was a few hours of work and a small PR. Nothing dramatic.</p>

<p><strong>Five. Ship the security fixes we had queued up.</strong></p>

<p>This one was a bonus. The TypeORM upgrade gave us a single PR window where every service was already being touched. We pulled in a handful of unrelated security findings (bugs where an attacker could swap in another user's ID, and write endpoints that let one user modify another user's data) and shipped them in the same staging branch. <em>"While we are in here"</em> is a real shipping pattern; we used it once, deliberately, on the boundary the upgrade had opened anyway.</p>

<h2 id="two-conflicts-we-planned-for-and-didnt-need">Two conflicts we planned for and didn't need</h2>

<p>Two conflicts I want to call out because they are the ones I was expecting and did not get.</p>

<p><strong>Production rollback.</strong> We had a plan. The plan was <em>"deploy the new image, watch the error rate, roll back if anything spikes."</em> We did not need it. The week of staging burn-in caught everything that would have spiked.</p>

<p><strong>Migration runner.</strong> TypeORM 1.0 has a different migrations API than 0.x. We were prepared to rewrite our migration files. We did not need to. The existing migrations are run-once historical records that the new runner reads cleanly without changes. The <em>new</em> migrations going forward use the new API, but the catalogue of historical migrations is untouched.</p>

<h2 id="what-wed-do-again-on-the-next-one">What we'd do again on the next one</h2>

<ol>
  <li><strong>Test harness first, runtime second.</strong> If the harness is broken, every other PR is blocked. If the runtime is broken, only the upgrade PR is blocked.</li>
  <li><strong>Pin the new base image on a parallel branch.</strong> Node version upgrades that come bundled with the main one should be tested as their own change before the code change lands on top.</li>
  <li><strong>Codemod the global API changes in one commit.</strong> <code class="language-plaintext highlighter-rouge">findOne()</code> → <code class="language-plaintext highlighter-rouge">findOneBy()</code> is hundreds of callsites. One mechanical commit beats fifty thoughtful ones.</li>
  <li><strong>Keep the staging branch alive for a week.</strong> Let other PRs rebase onto it. The conflicts are real but they are bounded if the staging branch is fresh.</li>
  <li><strong>Pull in the <em>"while we are in here"</em> fixes deliberately.</strong> Big PRs are scary, but a major version upgrade is the one PR window where every file gets touched anyway. The marginal cost of an extra security fix is much lower in that window than in any other.</li>
</ol>

<aside class="post-cta">
  <h2 id="we-could-run-your-major-dependency-upgrade">We Could Run Your Major Dependency Upgrade</h2>

  <p>If your team has a major-version dependency upgrade (TypeORM, Prisma, Next.js, React, NestJS, a Node base-image jump) that has been on the backlog for a quarter because nobody has had the bandwidth to plan it without stalling the sprint, <strong>Clearview Team</strong> has shipped a few of those. Your feature team keeps shipping while the upgrade lands on a parallel staging branch, the test harness moves ahead of the runtime so nobody's CI is broken mid-week, and production sees the new version without a single dropped request. Send us the version-from and the version-to; we'll scope it.</p>

  <p><a href="mailto:info@clearview.team?subject=Major%20dependency%20upgrade%20enquiry">Brief us on your dependency upgrade →</a></p>
</aside>]]></content><author><name>Taufan Fadhilah</name></author><category term="backend" /><category term="typescript" /><category term="nestjs" /><category term="typeorm" /><category term="postgres" /><category term="dependency-upgrade" /><category term="case-study" /><category term="nodejs-backend-refactors" /><summary type="html"><![CDATA[A backend service I work on runs on TypeORM. Earlier this year we jumped from 0.x to the new 1.0 release. The old line had gone almost five years without a breaking change, and a class of quietly-wrong-answer bugs had piled up in it: queries that returned bad data instead of raising an error. The 1.0 release fixes them by making the bad queries fail loudly instead. It also forced an @nestjs/typeorm upgrade at the same time, since the NestJS glue layer for TypeORM crashes at startup on the new TypeORM if you are still on the old version. But the catch with any major ORM upgrade is that every entity, every query builder, every database factory in the test suite gets touched.]]></summary></entry><entry><title type="html">How a CloudFront Custom Error Page Leaked JWTs to S3: A Case Study</title><link href="https://blog.clearview.team/2026/cloudfront-error-page-jwt-leak-case-study/" rel="alternate" type="text/html" title="How a CloudFront Custom Error Page Leaked JWTs to S3: A Case Study" /><published>2026-08-13T11:00:00+02:00</published><updated>2026-08-13T11:00:00+02:00</updated><id>https://blog.clearview.team/2026/cloudfront-error-page-jwt-leak-case-study</id><content type="html" xml:base="https://blog.clearview.team/2026/cloudfront-error-page-jwt-leak-case-study/"><![CDATA[<p>A client was preparing to put an API behind CloudFront in production. During the final penetration test, one <code class="language-plaintext highlighter-rouge">curl</code> request showed that every distribution with custom error pages was reflecting users' JWT tokens in S3 XML error responses.</p>

<p>Sixteen distributions shared the same copy-pasted Terraform block.</p>

<p>CloudFront is AWS's content delivery network (CDN). A JSON Web Token (JWT) is the signed session string the API gives a user after login. <code class="language-plaintext highlighter-rouge">curl</code> is the command-line tool we use to send HTTP requests by hand. Three terms are enough to follow the finding.</p>

<p>CloudFront was handling TLS at the edge, HTTP/2 and HTTP/3 connections, DDoS protection, and branded error pages from a separate S3 bucket. The staging setup had been running for weeks. The distributions were stable, the error pages rendered correctly, and the TLS grades looked good.</p>

<h2 id="the-one-line-proof">The One-Line Proof</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl https://your-api.example.com/custom_error_pages/502.html <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.YOUR_ACTUAL_TOKEN"</span>
</code></pre></div></div>

<p>If your response looks like this, you're vulnerable:</p>

<div class="language-xml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;?xml version="1.0" encoding="UTF-8"?&gt;</span>
<span class="nt">&lt;Error&gt;</span>
  <span class="nt">&lt;Code&gt;</span>InvalidArgument<span class="nt">&lt;/Code&gt;</span>
  <span class="nt">&lt;Message&gt;</span>Unsupported Authorization Type<span class="nt">&lt;/Message&gt;</span>
  <span class="nt">&lt;ArgumentName&gt;</span>Authorization<span class="nt">&lt;/ArgumentName&gt;</span>
  <span class="nt">&lt;ArgumentValue&gt;</span>Bearer eyJhbGciOiJIUzI1NiJ9.YOUR_ACTUAL_TOKEN<span class="nt">&lt;/ArgumentValue&gt;</span>
<span class="nt">&lt;/Error&gt;</span>
</code></pre></div></div>

<p>Your full JWT (user ID, email, role, expiry, everything in the payload) reflected back in an S3 XML error response. The path exists on practically every CloudFront distribution with custom error pages. Any GET request with an <code class="language-plaintext highlighter-rouge">Authorization</code> header triggers it, no authentication and no origin failure required.</p>

<p>S3 reflects any <code class="language-plaintext highlighter-rouge">Authorization</code> scheme, not just Bearer. Basic auth credentials (<code class="language-plaintext highlighter-rouge">Basic dXNlcjpwYXNzd29yZA==</code>), API tokens (<code class="language-plaintext highlighter-rouge">Token API_TOKEN_VALUE</code>), anything non-AWS gets dumped into the XML. If your API uses any of these, the same leak applies.</p>

<p>Cookies, <code class="language-plaintext highlighter-rouge">X-API-Key</code>, and custom headers are forwarded to S3 too but not reflected in the XML. S3 only complains about the one header it tries to parse.</p>

<h2 id="why-did-cloudfront-forward-the-authorization-header-to-s3">Why did CloudFront forward the <code class="language-plaintext highlighter-rouge">Authorization</code> header to S3?</h2>

<p>A CloudFront distribution sits between users and one or more <strong>origins</strong> (the actual servers that hold your content). Each origin gets a <strong>behavior</strong> that tells CloudFront which requests to route there and what to forward along.</p>

<p>In this pattern, there are two origins behind one distribution:</p>

<figure class="post-figure--wide">
  <img src="/assets/images/posts/cloudfront-error-page-jwt-leak-case-study/topology.svg" alt="CloudFront distribution topology: the default behavior routes /api/* to the ALB with all viewer headers (correct), while the ordered behavior routes /custom_error_pages/* to S3 with the same headers (misconfigured), causing the JWT to leak." loading="lazy" />
  <figcaption>The API behavior needs viewer headers. The S3 error-page behavior does not.</figcaption>
</figure>

<p>The API origin is your application: an Application Load Balancer (ALB) in front of Elastic Container Service (ECS) containers, a Lambda function, or an EC2 instance. It needs the viewer's <code class="language-plaintext highlighter-rouge">Authorization</code> header to authenticate requests, <code class="language-plaintext highlighter-rouge">Content-Type</code> to parse bodies, and cookies for sessions. Forwarding everything to this origin is correct.</p>

<p>The error page origin is an S3 bucket containing three static HTML files: <code class="language-plaintext highlighter-rouge">502.html</code>, <code class="language-plaintext highlighter-rouge">503.html</code>, and <code class="language-plaintext highlighter-rouge">404.html</code>. It serves the same branded "We'll be right back" page to every user regardless of who they are.</p>

<p>It needs nothing from the viewer: not their identity, not their cookies, not their query strings. It just needs CloudFront to ask for a file by path, and it returns the HTML.</p>

<p>The problem is that both origins were given the same origin request policy: the one designed for the API. So when CloudFront routes a request to the S3 error page bucket, it sends along everything the viewer included, including <code class="language-plaintext highlighter-rouge">Authorization: Bearer &lt;JWT&gt;</code>.</p>

<p>S3 is not your API. It doesn't understand Bearer tokens. It tries to interpret the <code class="language-plaintext highlighter-rouge">Authorization</code> header as AWS Signature Version 4, fails, and returns an XML error that includes the header value it couldn't parse. Your JWT is now in the response body.</p>

<p>The setup looks like this in Terraform (CloudFormation and the console follow the same pattern):</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Origin 1: Your API (ALB, ECS, Lambda, etc.)</span>
<span class="nx">origin</span> <span class="p">{</span>
  <span class="nx">domain_name</span> <span class="o">=</span> <span class="nx">aws_lb</span><span class="p">.</span><span class="nx">api</span><span class="p">.</span><span class="nx">dns_name</span>
  <span class="nx">origin_id</span>   <span class="o">=</span> <span class="s2">"api"</span>
<span class="p">}</span>

<span class="c1"># Origin 2: S3 bucket with static error pages</span>
<span class="nx">origin</span> <span class="p">{</span>
  <span class="nx">domain_name</span> <span class="o">=</span> <span class="nx">aws_s3_bucket</span><span class="p">.</span><span class="nx">error_pages</span><span class="p">.</span><span class="nx">website_endpoint</span>
  <span class="nx">origin_id</span>   <span class="o">=</span> <span class="s2">"error-pages"</span>
<span class="p">}</span>

<span class="c1"># When the API returns 502, serve the S3 error page instead</span>
<span class="nx">custom_error_response</span> <span class="p">{</span>
  <span class="nx">error_code</span>         <span class="o">=</span> <span class="mi">502</span>
  <span class="nx">response_page_path</span> <span class="o">=</span> <span class="s2">"/custom_error_pages/502.html"</span>
<span class="p">}</span>

<span class="c1"># Behavior for the API (default): forwards all viewer headers</span>
<span class="nx">default_cache_behavior</span> <span class="p">{</span>
  <span class="nx">target_origin_id</span>         <span class="o">=</span> <span class="s2">"api"</span>
  <span class="nx">origin_request_policy_id</span> <span class="o">=</span> <span class="nx">aws_cloudfront_origin_request_policy</span><span class="p">.</span><span class="nx">AllViewerExceptHostHeader</span><span class="p">.</span><span class="nx">id</span>
<span class="p">}</span>

<span class="c1"># Behavior for the error pages: ALSO forwards all viewer headers</span>
<span class="nx">ordered_cache_behavior</span> <span class="p">{</span>
  <span class="nx">path_pattern</span>             <span class="o">=</span> <span class="s2">"/custom_error_pages/*"</span>
  <span class="nx">target_origin_id</span>         <span class="o">=</span> <span class="s2">"error-pages"</span>
  <span class="nx">origin_request_policy_id</span> <span class="o">=</span> <span class="nx">aws_cloudfront_origin_request_policy</span><span class="p">.</span><span class="nx">AllViewerExceptHostHeader</span><span class="p">.</span><span class="nx">id</span>
  <span class="c1"># ↑ THIS IS THE PROBLEM</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">AllViewerExceptHostHeader</code> policy tells CloudFront: "forward every header the viewer sent (<code class="language-plaintext highlighter-rouge">Authorization</code>, <code class="language-plaintext highlighter-rouge">Cookie</code>, <code class="language-plaintext highlighter-rouge">X-Custom-Whatever</code>) to the origin, except <code class="language-plaintext highlighter-rouge">Host</code>." That's correct for the API behavior, where your backend needs those headers. It's catastrophically wrong for the error page behavior, where the origin is an S3 bucket serving static HTML.</p>

<aside class="callout">
  <p><strong>A note on policy names.</strong> We call out <code class="language-plaintext highlighter-rouge">AllViewerExceptHostHeader</code> because that's what was on the error page behaviors we audited. But AWS's managed policy <strong><code class="language-plaintext highlighter-rouge">Managed-AllViewer</code></strong> does the same thing. Any policy with <code class="language-plaintext highlighter-rouge">header_behavior = "allViewer"</code> or <code class="language-plaintext highlighter-rouge">header_behavior = "allExcept"</code> that doesn't explicitly exclude <code class="language-plaintext highlighter-rouge">Authorization</code> will trigger the same leak.</p>

  <p>In CloudFormation, look for <code class="language-plaintext highlighter-rouge">OriginRequestPolicyId</code> on your error page <code class="language-plaintext highlighter-rouge">CacheBehavior</code>. In CDK, check <code class="language-plaintext highlighter-rouge">originRequestPolicy</code> on your <code class="language-plaintext highlighter-rouge">BehaviorOptions</code>. The policy names and IDs are the same; only the syntax differs.</p>
</aside>

<h2 id="three-ways-the-token-leaks">Three ways the token leaks</h2>

<p>The error page path isn't linked anywhere. No browser navigates to <code class="language-plaintext highlighter-rouge">/custom_error_pages/502.html</code> during normal use. Three practical routes still reach it.</p>

<p>The path is trivially discoverable by scanners. It's in CloudFront's own documentation examples, and tools like ffuf, feroxbuster, and nuclei include <code class="language-plaintext highlighter-rouge">/error/</code>, <code class="language-plaintext highlighter-rouge">/custom_error_pages/</code>, and <code class="language-plaintext highlighter-rouge">/502.html</code> in their default wordlists. A scanner that hits the path and sees <code class="language-plaintext highlighter-rouge">&lt;Code&gt;InvalidArgument&lt;/Code&gt;</code> with <code class="language-plaintext highlighter-rouge">&lt;ArgumentName&gt;Authorization&lt;/ArgumentName&gt;</code> in the XML response can identify the misconfiguration from the shape alone. Our pentest tooling found this one that way.</p>

<p>A production outage can leak the token without any scanner. When the origin returns 502/503, CloudFront intercepts the error and fetches the S3 error page with the original request's <code class="language-plaintext highlighter-rouge">Authorization</code> header attached. The user didn't navigate to the error page; CloudFront sent them there. Their token leaks through a normal deployment, container restart, or scaling event, visible in the response body to any browser extension, corporate proxy, or compromised CDN edge watching that window.</p>

<table>
  <thead>
    <tr>
      <th>Path</th>
      <th>Trigger</th>
      <th>Result</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Direct access</td>
      <td>A GET request with an <code class="language-plaintext highlighter-rouge">Authorization</code> header reaches <code class="language-plaintext highlighter-rouge">/custom_error_pages/502.html</code>.</td>
      <td>S3 reflects the header in XML. No origin failure is needed.</td>
    </tr>
    <tr>
      <td>Origin failure</td>
      <td>An API request carries a JWT, then the origin returns 502 or 503.</td>
      <td>CloudFront fetches the S3 error page with the original headers, and the token comes back to the user.</td>
    </tr>
    <tr>
      <td>Plain HTTP</td>
      <td>The error-page behavior uses <code class="language-plaintext highlighter-rouge">viewer_protocol_policy = "allow-all"</code>.</td>
      <td>A network attacker can read the token without breaking its cryptography.</td>
    </tr>
  </tbody>
</table>

<p>Most of the distributions we audited had the plain-HTTP setting. The API behavior used <code class="language-plaintext highlighter-rouge">redirect-to-https</code>; the error-page behavior was a separate block with separate settings.</p>

<h2 id="the-terraform-template-problem">The Terraform Template Problem</h2>

<p>We found this in a client's infrastructure codebase. We audited every CloudFront distribution across their staging and production accounts. The same <code class="language-plaintext highlighter-rouge">ordered_cache_behavior</code> block for <code class="language-plaintext highlighter-rouge">/custom_error_pages/*</code> showed up on distribution after distribution, all pointing to the same S3 error pages bucket, all using <code class="language-plaintext highlighter-rouge">AllViewerExceptHostHeader</code>.</p>

<p>The block was written once for the API distribution, where it was correct. Then it was copied to every other distribution (SPAs, admin dashboards, static sites, upload CDNs) because it "worked" and nobody questioned whether a static error page bucket needed to receive the viewer's <code class="language-plaintext highlighter-rouge">Authorization</code> header.</p>

<p>Terraform's copy-paste turned one mistake into a pattern across the whole account.</p>

<p>A handful of the affected distributions served authenticated traffic (APIs and legacy apps). Those were fully exploitable: every authenticated request that hit a 502 leaked the caller's JWT. The rest served static frontends that don't normally receive <code class="language-plaintext highlighter-rouge">Authorization</code> headers, but the direct-access path still worked on all of them.</p>

<h2 id="the-chain-that-made-it-critical">The Chain That Made It Critical</h2>

<p>The JWT leak alone is a HIGH: you can capture a token and replay it for the remaining access-token lifetime. But during this engagement, we found it chained with something else that pushed it to CRITICAL.</p>

<p>The API signs session tokens with a JWT secret. On staging, that secret was hardcoded as a trivially guessable value in the ECS task definition's plaintext environment block. Not in Secrets Manager, not pulled from SSM Parameter Store, just a short, dictionary-word string sitting in Terraform.</p>

<p>We confirmed the secret remotely without any internal access. The API returns different error messages depending on whether a token's signature verifies:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Token signed with the guessable secret (correct):</span>
curl <span class="nt">-X</span> POST https://api.staging.example.com/api/session/token <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Cookie: session=&lt;forged-token-signed-with-guessable-secret&gt;"</span>
<span class="c"># → "Session has been revoked" (signature PASSED, DB hash lookup failed)</span>

<span class="c"># Token signed with wrong secret:</span>
curl <span class="nt">-X</span> POST https://api.staging.example.com/api/session/token <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Cookie: session=&lt;token-with-wrong-signature&gt;"</span>
<span class="c"># → "Invalid session" (signature FAILED)</span>
</code></pre></div></div>

<p>That error difference acts as an oracle. "Session has been revoked" means the JWT signature verified successfully; the server moved past cryptographic verification to the database lookup phase. "Invalid session" means the signature check failed. The two code paths are distinguishable remotely.</p>

<h3 id="we-proved-the-full-chain-end-to-end">We proved the full chain end-to-end</h3>

<p>We registered a test account and signed in. We captured the session token, leaked it through the error page, and validated it offline. Then we replayed it and had the account. All with curl.</p>

<p><strong>Step 1: Sign in.</strong> We registered a test account and signed in. The server set an httpOnly session cookie. It was a long-lived JWT signed with the guessable secret.</p>

<p><strong>Step 2: Leak the token.</strong> We sent a GET request to the error page path. The session token was in the <code class="language-plaintext highlighter-rouge">Authorization</code> header. The S3 XML error returned it verbatim. Byte for byte, it matched the original.</p>

<p><strong>Step 3: Validate offline.</strong> We computed the HMAC-SHA256 signature using the guessable secret. It matched the token's signature exactly. No server interaction needed. The attacker now knows the token is real, who it belongs to, and when it expires.</p>

<p><strong>Step 4: Replay.</strong> We sent the leaked session token to the token endpoint. The server verified the JWT signature (passed) and looked up the token hash (found a real active session). It minted a fresh access token.</p>

<p><strong>Step 5: Account takeover.</strong> We used the fresh access token to call the user-profile endpoint. The server returned the full profile: ID, email, name, role, all PII fields. Full account access with no password. All from a token that was never meant to leave the httpOnly cookie jar.</p>

<figure class="post-figure--wide">
  <img src="/assets/images/posts/cloudfront-error-page-jwt-leak-case-study/attack-chain.svg" alt="Full attack chain: register a test account, sign in, leak the session JWT through the error page, validate the signature offline with the guessable secret, replay the token to get a fresh access token, call GET /me for full account takeover." loading="lazy" />
  <figcaption>The leak becomes account takeover when the staging JWT secret is guessable.</figcaption>
</figure>

<p>We ran the entire chain with curl against a live staging environment. We didn't need source code access to exploit it. We only needed it to discover the hardcoded secret in Terraform. An external attacker reaches the same outcome by guessing the secret or by intercepting a token through the error page leak.</p>

<h2 id="the-public-bucket">The public bucket</h2>

<p>The bucket is also enumerable:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl https://your-error-pages-bucket.s3.amazonaws.com/
</code></pre></div></div>

<p>Returns a full <code class="language-plaintext highlighter-rouge">ListBucketResult</code> XML with every file, size, ETag, and last-modified timestamp, no authentication required.</p>

<p>It's public because the error page origin uses an S3 <strong>website endpoint</strong>, which forces public read access. OAC (Origin Access Control) only works with the REST API endpoint. The pages themselves are benign HTML, but the bucket is one IAM policy drift away from attacker-writable: a phishing form in a 502 page would be served by your own domain, from your own CloudFront distribution, with your own TLS certificate.</p>

<h2 id="the-fix">The Fix</h2>

<p>The error page behavior serves static HTML from S3. It doesn't need the viewer's auth token, cookies, query strings, or any other header.</p>

<p>When you omit <code class="language-plaintext highlighter-rouge">origin_request_policy_id</code> entirely, CloudFront sends only the minimum required headers to the origin. That is enough for an S3 website endpoint to fetch a file by path, and there is nothing left for S3 to reflect back.</p>

<p>The simplest fix is to delete the <code class="language-plaintext highlighter-rouge">origin_request_policy_id</code> line from every error page behavior. It shouldn't have been there in the first place.</p>

<p>But if you want to be explicit about the intent (and make it harder for someone to re-add a permissive policy later thinking it was accidentally removed), define a policy that forwards nothing:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">resource</span> <span class="s2">"aws_cloudfront_origin_request_policy"</span> <span class="s2">"ErrorPagesNoAuth"</span> <span class="p">{</span>
  <span class="nx">name</span>    <span class="o">=</span> <span class="s2">"ErrorPages-NoAuth"</span>
  <span class="nx">comment</span> <span class="o">=</span> <span class="s2">"Error pages are static HTML, no viewer headers needed"</span>

  <span class="nx">headers_config</span> <span class="p">{</span>
    <span class="nx">header_behavior</span> <span class="o">=</span> <span class="s2">"none"</span>
  <span class="p">}</span>
  <span class="nx">cookies_config</span> <span class="p">{</span>
    <span class="nx">cookie_behavior</span> <span class="o">=</span> <span class="s2">"none"</span>
  <span class="p">}</span>
  <span class="nx">query_strings_config</span> <span class="p">{</span>
    <span class="nx">query_string_behavior</span> <span class="o">=</span> <span class="s2">"none"</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Then in every <code class="language-plaintext highlighter-rouge">ordered_cache_behavior</code> for <code class="language-plaintext highlighter-rouge">/custom_error_pages/*</code>:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">ordered_cache_behavior</span> <span class="p">{</span>
  <span class="nx">path_pattern</span>             <span class="o">=</span> <span class="s2">"/custom_error_pages/*"</span>
  <span class="nx">target_origin_id</span>         <span class="o">=</span> <span class="s2">"error-pages"</span>
  <span class="nx">origin_request_policy_id</span> <span class="o">=</span> <span class="nx">aws_cloudfront_origin_request_policy</span><span class="p">.</span><span class="nx">ErrorPagesNoAuth</span><span class="p">.</span><span class="nx">id</span>
  <span class="nx">viewer_protocol_policy</span>   <span class="o">=</span> <span class="s2">"redirect-to-https"</span>
  <span class="c1"># ... rest unchanged</span>
<span class="p">}</span>
</code></pre></div></div>

<p><strong>Verification:</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Before fix:</span>
curl https://your-api.com/custom_error_pages/502.html <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Authorization: Bearer TEST"</span>
<span class="c"># → &lt;ArgumentValue&gt;Bearer TEST&lt;/ArgumentValue&gt;  ← LEAKED</span>

<span class="c"># After fix:</span>
curl https://your-api.com/custom_error_pages/502.html <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Authorization: Bearer TEST"</span>
<span class="c"># → &lt;!DOCTYPE html&gt;&lt;html&gt;...(your 502 page HTML)...  ← SAFE</span>
</code></pre></div></div>

<p>For defense in depth:</p>

<ul>
  <li>Switch the S3 origin from a public website endpoint to a private bucket with OAC. This closes the public listing vulnerability and eliminates the unencrypted HTTP origin protocol.</li>
  <li>Set <code class="language-plaintext highlighter-rouge">error_caching_min_ttl = 0</code> so error responses aren't cached at all.</li>
  <li>Set <code class="language-plaintext highlighter-rouge">viewer_protocol_policy = "redirect-to-https"</code> on the error page behavior so the path can't be accessed over plain HTTP.</li>
</ul>

<p>Forwarding headers to error pages <em>is</em> valid when the origin needs the viewer's identity: a Lambda@Edge function rendering personalized error pages, a Cognito-gated bucket, or a debugging service logging which user hit the error. In those cases the origin is an application that can safely consume auth headers. If your error page origin is S3, the answer is always <code class="language-plaintext highlighter-rouge">header_behavior = "none"</code>.</p>

<h2 id="what-we-shipped">What we shipped</h2>

<p>Sixteen distributions closed. The JWT reflection path is gone across the client's account, and HTTP-to-HTTPS is enforced on every error-page behavior. The one residual risk (the public S3 website endpoint) is on the follow-up list.</p>

<h2 id="how-to-check-if-youre-vulnerable">How to Check If You're Vulnerable</h2>

<p><strong>Step 1: Find your error page path.</strong> Look for <code class="language-plaintext highlighter-rouge">custom_error_response</code> blocks in your CloudFront distribution. The <code class="language-plaintext highlighter-rouge">response_page_path</code> tells you where the error pages live.</p>

<p><strong>Step 2: Send a request with an auth header.</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-s</span> https://your-domain.com/custom_error_pages/502.html <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Authorization: Bearer CHECK_THIS_TOKEN"</span>
</code></pre></div></div>

<p><strong>Step 3: Check the response.</strong> If you see <code class="language-plaintext highlighter-rouge">&lt;ArgumentValue&gt;Bearer CHECK_THIS_TOKEN&lt;/ArgumentValue&gt;</code> in the XML, you're vulnerable. If you see your HTML error page, you're fine. A 403 means the path doesn't exist or the behavior doesn't allow GET, which is also fine.</p>

<p><strong>Step 4: Check all your distributions.</strong> If you use the same Terraform module or copy-paste pattern across distributions, check every one. In one engagement we found the majority of distributions sharing the same misconfiguration. The one you check might be fine while a dozen others are leaking.</p>

<h2 id="check-every-distribution">Check every distribution</h2>

<p>Nobody asked whether a static HTML bucket needed the same headers as an authenticated API. If you're running CloudFront with custom S3 error pages, spend sixty seconds running the curl command above against each distribution.</p>

<p><em>A note on responsible testing: every test account created during this engagement was registered with a non-existent <code class="language-plaintext highlighter-rouge">@example.com</code> address, used only for curl-based proof-of-concept, and cleaned up after the findings were documented. No real user sessions were intercepted or replayed. The full chain was proven end-to-end using our own credentials against a staging environment with explicit authorization from the infrastructure owner.</em></p>

<aside class="post-cta">
  <h2 id="we-could-scan-your-cloudfront-for-this">We Could Scan Your CloudFront For This</h2>

  <p>If your infrastructure has CloudFront distributions with custom S3 error pages and the same Terraform block copy-pasted across environments, one curl command will show whether your team has the same leak. <strong>Clearview Team</strong> runs the scan across every distribution in your account, walks the full exploitation chain wherever a leaked token gives real access, and hands the fix back as a pull request. No secret is guessed and no live session replayed without your explicit sign-off. Send us the AWS account and we will scope it.</p>

  <p><a href="mailto:info@clearview.team?subject=CloudFront%20audit%20enquiry">Scope a CloudFront audit →</a></p>
</aside>]]></content><author><name>Nedim Hadzimahmutovic</name></author><category term="aws" /><category term="cloudfront" /><category term="s3" /><category term="terraform" /><category term="jwt" /><category term="security" /><category term="api-security" /><category term="web-api-security" /><category term="aws-devops" /><category term="auth-architecture" /><category term="case-study" /><summary type="html"><![CDATA[A client was preparing to put an API behind CloudFront in production. During the final penetration test, one curl request showed that every distribution with custom error pages was reflecting users' JWT tokens in S3 XML error responses.]]></summary></entry><entry><title type="html">Implementing a Zero Auth Unsubscribe Link on Your Email</title><link href="https://blog.clearview.team/2026/signed-tokens-for-email-unsubscribe/" rel="alternate" type="text/html" title="Implementing a Zero Auth Unsubscribe Link on Your Email" /><published>2026-08-10T11:00:00+02:00</published><updated>2026-08-10T11:00:00+02:00</updated><id>https://blog.clearview.team/2026/signed-tokens-for-email-unsubscribe</id><content type="html" xml:base="https://blog.clearview.team/2026/signed-tokens-for-email-unsubscribe/"><![CDATA[<p>How often you receive an email that has no unsubscribe link at the bottom and then gets annoyed because you can't stop receiving this email and you end up with 314.159.265 unread emails?</p>

<p>Probably not often because unsubscription is now something that you have to legally think of, especially if you send marketing emails.</p>

<p>So how do you implement an unsubscribe link that is highly accessible, doesn't require any login, secure and unspoofable, and respect the RFC 8058 so that most email clients can automatically unsubscribe the user using your link? An email that respect your user so that they won't tap on that "Mark as spam" instead; and an email that won't trigger accidental unsubscribe because the email client crawls through every link to check for viruses.</p>

<dl class="post-glossary" data-label="A few words to travel with">
  <div>
    <dt>JWT</dt>
    <dd>JSON Web Token. A signed string with three parts (header, payload, signature) separated by dots.</dd>
  </div>
  <div>
    <dt>HMAC · HS256</dt>
    <dd>A keyed hash. A shared secret plus the payload produces a signature that only holders of the secret can create.</dd>
  </div>
  <div>
    <dt>RFC 8058</dt>
    <dd>The "one-click unsubscribe" standard that Gmail and Apple Mail support via the `List-Unsubscribe` and `List-Unsubscribe-Post` headers.</dd>
  </div>
  <div>
    <dt>kid</dt>
    <dd>A JWT header field naming which signing key was used, so a verifier can support key rotation — try current, then previous.</dd>
  </div>
</dl>

<h2 id="possible-approaches">Possible Approaches</h2>

<h3 id="random-token-in-the-database">Random Token In The Database</h3>

<p>The simplest approach would be to create a table that contains a token associated with an email that the user can use to unsubscribe.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">unsubscribe_tokens</span> <span class="p">(</span>
  <span class="n">token</span> <span class="n">uuid</span> <span class="k">PRIMARY</span> <span class="k">KEY</span><span class="p">,</span>
  <span class="n">user_id</span> <span class="n">uuid</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
  <span class="n">email</span> <span class="nb">varchar</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
  <span class="n">created_at</span> <span class="nb">timestamp</span> <span class="k">NOT</span> <span class="k">NULL</span>
<span class="p">)</span>
</code></pre></div></div>

<p>Generate a UUID per email, write the row, put the token in the URL. Endpoint gets hit, looks up the token, finds the user.</p>

<p>The problem with this approach is that you have to create a lot of them, and if you want to keep track of where the user unsubscribed from, you will create one token per email. For email that are sent 4 times a month for 100.000 users, that's 400.000 inserts on this table. By the end of the year, you will have 4.8M rows.</p>

<h3 id="signed-token-in-the-email">Signed Token In The Email</h3>

<p>The alternative is to use the email itself as the storage of the token. A signed token that can't be spoofed and by default "distributed".</p>

<p>JWT is a good candidates for this, because we can sign it and then encode it as a link on the email.</p>

<p>The minimum fields that we need is just the email. That's it — of course you can add additional fields for tracking and other verifications. But you just need an email associated with the token and sign it and you're good to go.</p>

<p>We sign it with a shared HMAC secret — or if you're distributed and do key-pair verification, you can always do it too:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">function</span> <span class="nf">signEmailSubscriptionToken</span><span class="p">(</span>
  <span class="nx">payload</span><span class="p">:</span> <span class="nx">EmailSubscriptionTokenPayload</span><span class="p">,</span>
<span class="p">):</span> <span class="kr">string</span> <span class="p">{</span>
  <span class="k">return</span> <span class="nx">jwt</span><span class="p">.</span><span class="nf">sign</span><span class="p">(</span><span class="nx">payload</span><span class="p">,</span> <span class="nx">EMAIL_SUBSCRIPTION_SECRET</span><span class="p">,</span> <span class="p">{</span>
    <span class="na">algorithm</span><span class="p">:</span> <span class="dl">'</span><span class="s1">HS256</span><span class="dl">'</span><span class="p">,</span>
  <span class="p">});</span>
<span class="p">}</span>
</code></pre></div></div>

<p>And the URL that goes into the email footer looks like:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>https://example.com/manage-email-subscriptions?token=&lt;jwt-here&gt;
</code></pre></div></div>

<p><img src="/assets/images/posts/signed-tokens-for-email-unsubscribe/jwt-anatomy.svg" alt="Anatomy of a JWT: three base64url-encoded parts joined by dots. HEADER names the signing algorithm (HS256). PAYLOAD carries the claims — the email address and an issued-at timestamp. SIGNATURE is HMAC-SHA256 over the first two parts and a shared SECRET. The verifier recomputes the signature and compares — match means the token is valid, mismatch returns 401. Nothing about the token is secret; the secret is the SECRET." /></p>

<p>Ideally, this will open a page that will allow the user to manage their subscriptions, and only execute the unsubcription via user interactions and not on page load. Otherwise, crawler might accidentally trigger unsubscriptions when this URL is accessed.</p>

<p>So you will need another endpoint, that accepts the token as well, but called with POST method.</p>

<h2 id="is-this-safe">Is This Safe?</h2>

<p>There are three real threats to an unsubscribe URL:</p>

<p><strong>Someone forges a URL to unsubscribe a stranger.</strong> The signature stops this. You cannot produce a valid token without our secret. Try to change the <code class="language-plaintext highlighter-rouge">sub</code> claim on a token you already have, and the JWT verification fails immediately. There is no way to forge a token that verifies without the secret. That is the whole point of signing.</p>

<p><strong>A crawler enumerates URLs and unsubscribes everyone.</strong> The token is a random-looking base64 string that is different for every user. You cannot walk from one URL to the next. There is no <code class="language-plaintext highlighter-rouge">?userId=1234</code> you can increment to find the next one.</p>

<p><strong>Someone steals a token from a leaked email and tries to escalate</strong> — use it to change the user's password, or log in as them. The thing that stops it is <em>not the token</em> — it is the endpoint.</p>

<p>The unsubscribe endpoint only knows how to do one thing, and that thing is "change this user's email subscription preferences." It does not accept requests to change the password. It does not accept requests to issue a session cookie. It does not accept requests to update the email address. So, even if the token leaks — say the email got forwarded, or someone got access to an old inbox — the worst outcome is that a stranger unsubscribes them from an email.</p>

<p>And the only line of code you need to write to verify the token is just this:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">payload</span> <span class="o">=</span> <span class="nx">jwt</span><span class="p">.</span><span class="nf">verify</span><span class="p">(</span><span class="nx">token</span><span class="p">,</span> <span class="nx">EMAIL_SUBSCRIPTION_SECRET</span><span class="p">);</span>
</code></pre></div></div>

<h2 id="rfc-8058">RFC 8058</h2>

<p>Gmail and Apple Mail have supported <a href="https://datatracker.ietf.org/doc/html/rfc8058">RFC 8058</a> for a while — the "one-click unsubscribe" standard. You set two headers on your outgoing email:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>List-Unsubscribe: &lt;https://example.com/one-click/unsubscribe?token=abc123&gt;
List-Unsubscribe-Post: List-Unsubscribe=One-Click
</code></pre></div></div>

<p>The URL above can be the POST endpoint that you also use for the actual unsubscription instead of the manage subscription page.</p>

<p>And Gmail renders a small "Unsubscribe" button next to the sender name. When the user taps it, Gmail POSTs to your URL on the user's behalf. The user never opens the email. They just tap once and the mail client confirms "unsubscribed."</p>

<p><img src="/assets/images/posts/signed-tokens-for-email-unsubscribe/one-click-flow.svg" alt="Sequence diagram of the RFC 8058 one-click unsubscribe flow: your API sends the email carrying a List-Unsubscribe header, the mail client (Gmail or Apple Mail) renders an &quot;Unsubscribe&quot; button next to the sender name, the user taps once, the mail client POSTs to your URL on the user's behalf, your API verifies the JWT in one line and returns 200 OK, and the mail client shows &quot;Unsubscribed.&quot; The user never opens the email." /></p>

<h2 id="a-few-things-worth-knowing">A few things worth knowing</h2>

<p>Be careful with the expiration of the token, some jurisdiction might require you to have specific expiration date. The easiest would just to not set any expiration date. I know it feels natural to set <code class="language-plaintext highlighter-rouge">expiresIn: '30d'</code> on the sign call.</p>

<p>Keep the payload small. URLs in emails are already long. Do not stuff extra fields into the token unless you have to.</p>

<p>Do log the unsubscribe. Not for legal reasons (although also for those) — for debugging. If a user complains that they were unsubscribed and did not remember doing it, having a log of when the token was verified and from what IP is very useful.</p>

<p>Rotate your secret. The signing key is a secret. If it leaks, every unsubscribe URL you have ever sent is compromisable. A <code class="language-plaintext highlighter-rouge">kid</code> field in the JWT header lets you support multiple keys, and the verifier can try current, then previous.</p>

<p>Feel free to share if you have a different approach to unsubscribing users from emails without asking them to log in. This pattern has worked for us but there is definitely more than one way to do it.</p>

<aside class="post-cta">
  <h2 id="we-could-implement-a-zero-auth-unsubscribe-link-for-you">We Could Implement A Zero-Auth Unsubscribe Link For You</h2>

  <p>If your emails still ask the user to log in before they can unsubscribe, your spam-marked rate is quietly climbing and you don't know why. <strong>Clearview Team</strong> helps you develop a solution — a signed token in the URL, one-click List-Unsubscribe headers, scope enforced at the endpoint.</p>

  <p><a href="mailto:info@clearview.team?subject=Email%20unsubscribe%20enquiry">Wire our unsubscribe →</a></p>
</aside>]]></content><author><name>Aditya Purwa</name></author><category term="email" /><category term="jwt" /><category term="auth" /><category term="backend" /><category term="unsubscribe" /><category term="auth-architecture" /><category term="case-study" /><summary type="html"><![CDATA[How often you receive an email that has no unsubscribe link at the bottom and then gets annoyed because you can't stop receiving this email and you end up with 314.159.265 unread emails?]]></summary></entry><entry><title type="html">Splitting a Mega-Service Into Four: The Service-Facade Refactor (Plus a Reusable Skill)</title><link href="https://blog.clearview.team/2026/splitting-a-mega-service-into-four/" rel="alternate" type="text/html" title="Splitting a Mega-Service Into Four: The Service-Facade Refactor (Plus a Reusable Skill)" /><published>2026-08-05T11:00:00+02:00</published><updated>2026-08-05T11:00:00+02:00</updated><id>https://blog.clearview.team/2026/splitting-a-mega-service-into-four</id><content type="html" xml:base="https://blog.clearview.team/2026/splitting-a-mega-service-into-four/"><![CDATA[<p>On a backend I work on, <code class="language-plaintext highlighter-rouge">user.service.ts</code> had crossed 1,307 lines. Nobody on the team could hold the whole file in their head. New hires bounced off it on day one. Reviews took twice as long because the reviewer did not remember the surrounding code the diff was sitting in, and the blame view was a quilt of contributors going back years.</p>

<p>We split the file into four sub-services behind a stable facade, landed the whole thing in one PR without changing a test, and wrote the procedure up as a reusable Claude skill for the next time.</p>

<h2 id="two-terms-before-we-go-further">Two terms before we go further</h2>

<ul>
  <li><strong>Facade</strong>, in this post, is a class that exposes the same public methods as the old service did, but delegates each method to the right one of the new sub-services. The old import path keeps working; the internal shape moves.</li>
  <li><strong>Sub-service</strong> is one of the new files the mega-service is split into. Each sub-service owns one slice of responsibility (<em>querying</em>, <em>mutating</em>, <em>membership-state</em>, <em>stats</em>) and is named after that slice.</li>
</ul>

<h2 id="the-split-we-shipped">The split we shipped</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>src/modules/user/
├── user.service.ts                   #  facade — was 1,307 lines, now ~120
├── user-query.service.ts             #  349 lines — read paths
├── user-mutation.service.ts          #  460 lines — write paths
├── user-membership.service.ts        #  288 lines — membership-state changes
├── user-stats.service.ts             #  307 lines — derived counts and aggregates
└── user.service.password-reset.spec.ts
</code></pre></div></div>

<p>The split ran along responsibility inside the service layer, not up through the controller and model layers. Reading a user and updating a user are different jobs on the same domain object, and they grow at different rates. On this service, the read methods stayed small and stable while the write methods kept picking up business rules.</p>

<p><strong>Query</strong> — anything that returned a user (or a list of users) without mutating state. <code class="language-plaintext highlighter-rouge">findById</code>, <code class="language-plaintext highlighter-rouge">findByEmail</code>, <code class="language-plaintext highlighter-rouge">findByCompany</code>, <code class="language-plaintext highlighter-rouge">searchByKeyword</code>. The query service does not write.</p>

<p><strong>Mutation</strong> — anything that wrote a user row (<code class="language-plaintext highlighter-rouge">create</code>, <code class="language-plaintext highlighter-rouge">update</code>, <code class="language-plaintext highlighter-rouge">updatePreferences</code>, <code class="language-plaintext highlighter-rouge">softDelete</code>, <code class="language-plaintext highlighter-rouge">restore</code>). The mutation service loads a row, mutates it, saves it.</p>

<p><strong>Membership</strong> — methods that flip the user's membership state: upgrade tier, downgrade tier, transfer membership, attach to company, detach from company. The state transitions are tangled enough to justify their own file.</p>

<p><strong>Stats</strong> — derived counts and aggregates. <em>"How many users in this company are active,"</em> <em>"how many memberships expire this month,"</em> <em>"how many users by role."</em> Reading across many rows is a different job from finding a single user.</p>

<p>A few methods did not fit any of the four. Some were doing two things and got split before they moved. Some were orchestration — they touched two or more sub-services in a single flow — and stayed on the facade.</p>

<h2 id="the-facade--keep-the-public-api-stable">The facade — keep the public API stable</h2>

<p>The facade is the file every other module imports. Its surface area is identical to the old service: every method other modules called still exists, with the same signature.</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// user.service.ts — the facade, after the split</span>
<span class="p">@</span><span class="nd">Service</span><span class="p">()</span>
<span class="k">export</span> <span class="kd">class</span> <span class="nc">UserService</span> <span class="p">{</span>
  <span class="nf">constructor</span><span class="p">(</span>
    <span class="k">private</span> <span class="k">readonly</span> <span class="nx">query</span><span class="p">:</span> <span class="nx">UserQueryService</span><span class="p">,</span>
    <span class="k">private</span> <span class="k">readonly</span> <span class="nx">mutation</span><span class="p">:</span> <span class="nx">UserMutationService</span><span class="p">,</span>
    <span class="k">private</span> <span class="k">readonly</span> <span class="nx">membership</span><span class="p">:</span> <span class="nx">UserMembershipService</span><span class="p">,</span>
    <span class="k">private</span> <span class="k">readonly</span> <span class="nx">stats</span><span class="p">:</span> <span class="nx">UserStatsService</span><span class="p">,</span>
  <span class="p">)</span> <span class="p">{}</span>

  <span class="c1">// ─── Read methods delegate to query ─────────────────────────────────</span>
  <span class="nx">findById</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">query</span><span class="p">.</span><span class="nx">findById</span><span class="p">.</span><span class="nf">bind</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">query</span><span class="p">);</span>
  <span class="nx">findByEmail</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">query</span><span class="p">.</span><span class="nx">findByEmail</span><span class="p">.</span><span class="nf">bind</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">query</span><span class="p">);</span>
  <span class="nx">findByCompany</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">query</span><span class="p">.</span><span class="nx">findByCompany</span><span class="p">.</span><span class="nf">bind</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">query</span><span class="p">);</span>
  <span class="nx">searchByKeyword</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">query</span><span class="p">.</span><span class="nx">searchByKeyword</span><span class="p">.</span><span class="nf">bind</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">query</span><span class="p">);</span>

  <span class="c1">// ─── Write methods delegate to mutation ─────────────────────────────</span>
  <span class="nx">create</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">mutation</span><span class="p">.</span><span class="nx">create</span><span class="p">.</span><span class="nf">bind</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">mutation</span><span class="p">);</span>
  <span class="nx">update</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">mutation</span><span class="p">.</span><span class="nx">update</span><span class="p">.</span><span class="nf">bind</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">mutation</span><span class="p">);</span>
  <span class="nx">updatePreferences</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">mutation</span><span class="p">.</span><span class="nx">updatePreferences</span><span class="p">.</span><span class="nf">bind</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">mutation</span><span class="p">);</span>
  <span class="nx">softDelete</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">mutation</span><span class="p">.</span><span class="nx">softDelete</span><span class="p">.</span><span class="nf">bind</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">mutation</span><span class="p">);</span>
  <span class="nx">restore</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">mutation</span><span class="p">.</span><span class="nx">restore</span><span class="p">.</span><span class="nf">bind</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">mutation</span><span class="p">);</span>

  <span class="c1">// ─── Membership ─────────────────────────────────────────────────────</span>
  <span class="nx">upgradeMembership</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">membership</span><span class="p">.</span><span class="nx">upgrade</span><span class="p">.</span><span class="nf">bind</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">membership</span><span class="p">);</span>
  <span class="nx">downgradeMembership</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">membership</span><span class="p">.</span><span class="nx">downgrade</span><span class="p">.</span><span class="nf">bind</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">membership</span><span class="p">);</span>
  <span class="nx">attachMemberToCompany</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">membership</span><span class="p">.</span><span class="nx">attachToCompany</span><span class="p">.</span><span class="nf">bind</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">membership</span><span class="p">);</span>

  <span class="c1">// ─── Stats ──────────────────────────────────────────────────────────</span>
  <span class="nx">countActiveByCompany</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">stats</span><span class="p">.</span><span class="nx">countActiveByCompany</span><span class="p">.</span><span class="nf">bind</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">stats</span><span class="p">);</span>
  <span class="nx">countExpiringMemberships</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">stats</span><span class="p">.</span><span class="nx">countExpiringMemberships</span><span class="p">.</span><span class="nf">bind</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">stats</span><span class="p">);</span>

  <span class="c1">// ─── Orchestration methods stay here ────────────────────────────────</span>
  <span class="c1">// Methods that genuinely coordinate across two or more sub-services.</span>
  <span class="k">async</span> <span class="nf">passwordResetFlow</span><span class="p">(</span><span class="nx">email</span><span class="p">:</span> <span class="kr">string</span><span class="p">,</span> <span class="nx">newPassword</span><span class="p">:</span> <span class="kr">string</span><span class="p">)</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">user</span> <span class="o">=</span> <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">query</span><span class="p">.</span><span class="nf">findByEmail</span><span class="p">(</span><span class="nx">email</span><span class="p">);</span>
    <span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">user</span><span class="p">)</span> <span class="k">return</span> <span class="p">{</span> <span class="na">success</span><span class="p">:</span> <span class="kc">false</span> <span class="p">};</span>

    <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">mutation</span><span class="p">.</span><span class="nf">updatePassword</span><span class="p">(</span><span class="nx">user</span><span class="p">.</span><span class="nx">id</span><span class="p">,</span> <span class="nx">newPassword</span><span class="p">);</span>
    <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">stats</span><span class="p">.</span><span class="nf">recordSecurityEvent</span><span class="p">(</span><span class="nx">user</span><span class="p">.</span><span class="nx">id</span><span class="p">,</span> <span class="dl">"</span><span class="s2">password-reset</span><span class="dl">"</span><span class="p">);</span>
    <span class="k">return</span> <span class="p">{</span> <span class="na">success</span><span class="p">:</span> <span class="kc">true</span> <span class="p">};</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">bind</code>-and-assign shape looks unusual on first read, but each method on the facade is the same function object as the one on the sub-service — no wrapper indirection, no extra logging. <code class="language-plaintext highlighter-rouge">userService.findById(42)</code> runs with the same overhead it always had.</p>

<p>Orchestration methods stay on the facade because they touch several sub-services in one flow. <code class="language-plaintext highlighter-rouge">passwordResetFlow</code> above hits the query, mutation, and stats services in sequence, so it belongs to none of them alone.</p>

<h2 id="how-the-split-landed-in-one-pr">How the split landed in one PR</h2>

<p>The PR added around sixteen hundred lines and deleted around twelve hundred. Most of it was file moves. The tests did not need to change: because the facade's public API is byte-identical to the old service, every test that imported <code class="language-plaintext highlighter-rouge">UserService</code> kept passing untouched. That is the safety net the whole refactor rides on.</p>

<p>Three disciplines made the PR reviewable.</p>

<p><strong>One sub-service per commit.</strong> Four code commits, one per sub-service, plus a fifth that thins the facade. The reviewer reads one sub-service at a time.</p>

<p><strong>Method-by-method migration.</strong> Each method moved with its tests. If a test in the suite was targeting a single method, that test stayed near the method, in the same commit as the move.</p>

<p><strong>No behavioural changes.</strong> Every method moved is byte-identical to what was in the old service. Behavioural refactors — N+1 fixes, added logging, tighter authorization — go in follow-up PRs after the split lands.</p>

<h2 id="the-reusable-skill--service-facade-refactor">The reusable skill — <code class="language-plaintext highlighter-rouge">service-facade-refactor</code></h2>

<p>The skill file lives at <code class="language-plaintext highlighter-rouge">.claude/skills/service-facade-refactor/SKILL.md</code> in this codebase and in our internal skill library. The next mega-service does not need to re-derive any of this.</p>

<p>A Claude skill is a small Markdown file documenting a reusable procedure in enough detail that an LLM-assisted contributor can apply it without having lived through the original work.</p>

<p>The skill in full:</p>

<div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gh"># Service Facade Refactor</span>

A pattern for splitting a single mega-service file into multiple
responsibility-tagged sub-services behind a stable public facade,
without breaking any caller.

<span class="gu">## When to use this skill</span>

A single service file has crossed roughly 800 lines, the team is
avoiding it on PRs, and the file mixes more than one of:
read paths, write paths, state-machine transitions, derived stats.

If the file is large but coherent (one responsibility, just verbose),
this is not the right skill — use a method-extraction refactor instead.

<span class="gu">## Inputs</span>
<span class="p">
-</span> The mega-service file (path).
<span class="p">-</span> The full test suite (must be green before starting).
<span class="p">-</span> One hour of uninterrupted reading time, before any code moves.

<span class="gu">## Steps</span>
<span class="p">
1.</span> <span class="gs">**Tag every method on the existing service.**</span> Read top-to-bottom.
   Each method gets exactly one tag:
<span class="p">   -</span> <span class="gs">**Read**</span> — returns the entity, no writes.
<span class="p">   -</span> <span class="gs">**Write**</span> — mutates the entity, may read first.
<span class="p">   -</span> <span class="gs">**State machine**</span> — tier upgrades, status transitions, lifecycle.
<span class="p">   -</span> <span class="gs">**Stats**</span> — counts, aggregates, derived values across many entities.
<span class="p">   -</span> <span class="gs">**Orchestration**</span> — coordinates across two or more of the above.

   If a method does not fit one tag, it is doing two things; split
   the method <span class="ge">*before*</span> you split the service.
<span class="p">
2.</span> <span class="gs">**Each non-orchestration tag becomes a sub-service.**</span>
   File naming: <span class="sb">`&lt;domain&gt;-&lt;tag&gt;.service.ts`</span>. Example: <span class="sb">`user-query.service.ts`</span>.
<span class="p">
3.</span> <span class="ge">**</span>Move methods one tag at a time, in their own commit, with
   their tests.<span class="ge">**</span> The test file moves with the method, or, if the
   tests are integration-style, stay in place and reference the
   sub-service through the facade.
<span class="p">
4.</span> <span class="gs">**Rewrite the original service file as a facade.**</span>
<span class="p">   -</span> For each method on a sub-service, <span class="sb">`bind`</span>-and-assign on the facade:
     <span class="sb">`findById = this.query.findById.bind(this.query);`</span>
<span class="p">   -</span> For orchestration methods (touch two or more sub-services), keep
     the implementation on the facade.
<span class="p">
5.</span> <span class="gs">**Run the full test suite. It should pass with no test changes.**</span>
   The facade's public API is byte-identical to the original service.
<span class="p">
6.</span> <span class="ge">**</span>Land as a single PR with one commit per sub-service plus one
   for the facade.<span class="ge">**</span> Behavioural changes (N+1 fixes, new logging,
   tightened authz) are <span class="ge">*follow-up PRs*</span>, not part of the split.

<span class="gu">## Output shape</span>
<span class="p">
-</span> One facade file, exposing the same methods the original did.
<span class="p">-</span> Four (or fewer) sub-service files, each named by its tag.
<span class="p">-</span> Zero test changes.
<span class="p">-</span> A single PR, multi-commit, readable one sub-service at a time.

<span class="gu">## What this is not</span>
<span class="p">
-</span> Not a layered split (controller / service / model). The split is
  by responsibility within the service layer, not by layer.
<span class="p">-</span> Not a behavioural change. Save the perf and authz fixes for after.
<span class="p">-</span> Not appropriate for files under ~500 lines. The overhead of the
  split is worse than the readability win at that size.
</code></pre></div></div>

<p>Step 1 is where the split gets decided. The tagging walks top-to-bottom on five yes/no questions, and the first "yes" wins.</p>

<figure>
  <img src="/assets/images/posts/splitting-a-mega-service-into-four/tagging-decision.svg" alt="Method-tagging decision flow. Five yes/no questions: does the method write to the database (Mutation), does it read the row and return it (Query), does it flip a status or lifecycle state (Membership), does it aggregate across many rows (Stats), or does it call two or more of the above (Orchestration — stays on the facade). If none of the above, split the method — it is doing two things." loading="lazy" width="1600" height="900" />
  <figcaption>First "yes" wins. A method that answers no to all five is doing two things and needs to be split before it is moved.</figcaption>
</figure>

<p>The skill works across codebases because it does not name a specific domain. Any TypeScript codebase with service classes and a mega-service problem can apply it the same way.</p>

<p>The judgement calls in the skill took longer to write than the steps. The <em>"if the file is large but coherent, this is not the right skill"</em> line at the top and the <em>"what this is not"</em> section at the bottom are what keep the skill from getting misapplied. A skill that only describes the happy path gets used wrong the first time somebody reaches for it.</p>

<p>The next mega-service on this codebase — <code class="language-plaintext highlighter-rouge">finance.service.ts</code>, around eleven hundred lines — is queued up for the same treatment.</p>

<aside class="post-cta">
  <h2 id="we-could-split-your-mega-service">We Could Split Your Mega-Service</h2>

  <p>We have run this swap before, and we can run it on yours. If your team has a service file that has crossed a thousand lines and PRs against it have started slowing down, <strong>Clearview Team</strong> takes it on as a one-week refactor: your team keeps shipping while we work, and when we hand it back, the file reads in one sitting, the callers are untouched, and the behavioural fixes you actually want are queued as separate follow-ups. Send us the file path and we will scope the split.</p>

  <p><a href="mailto:info@clearview.team?subject=Service%20facade%20refactor%20enquiry">Brief us on your mega-service →</a></p>
</aside>]]></content><author><name>Taufan Fadhilah</name></author><category term="backend" /><category term="typescript" /><category term="nestjs" /><category term="refactor" /><category term="architecture" /><category term="ai" /><category term="claude" /><category term="claude-skill" /><category term="case-study" /><category term="nodejs-backend-refactors" /><summary type="html"><![CDATA[On a backend I work on, user.service.ts had crossed 1,307 lines. Nobody on the team could hold the whole file in their head. New hires bounced off it on day one. Reviews took twice as long because the reviewer did not remember the surrounding code the diff was sitting in, and the blame view was a quilt of contributors going back years.]]></summary></entry><entry><title type="html">Iftar at Apetit: The Sarajevo Team Picks a Menu</title><link href="https://blog.clearview.team/2026/sarajevo-iftar-apetit/" rel="alternate" type="text/html" title="Iftar at Apetit: The Sarajevo Team Picks a Menu" /><published>2026-08-04T11:00:00+02:00</published><updated>2026-08-04T11:00:00+02:00</updated><id>https://blog.clearview.team/2026/sarajevo-iftar-apetit</id><content type="html" xml:base="https://blog.clearview.team/2026/sarajevo-iftar-apetit/"><![CDATA[<p>From time to time, the Sarajevo Clearview team meets in the same room. "Same room" takes work and dedication. Most days, the team is stretched across the globe in apartments in Sarajevo, Malang, and Cape Town, with a laptop on US East Coast time for a client.</p>

<p>The week before Ramadan, as it is the company's custom, Amina booked us a table for iftar at Apetit, the food spot everyone in Sarajevo recommends. It sits on Vilsonovo šetalište, the riverside promenade locals call <em>Vils</em>, five minutes from most of our apartments.</p>

<dl class="post-glossary" data-label="A few words to travel with">
  <div><dt>svejedno</dt><dd>whatever, fine with me</dd></div>
  <div><dt>može</dt><dd>works for me</dd></div>
  <div><dt>ekipa</dt><dd>the crew, our people</dd></div>
  <div><dt>fakat</dt><dd>honestly, for real</dd></div>
  <div><dt>meni</dt><dd>for me / put me down for</dd></div>
  <div><dt>pa kad je iftar</dt><dd>well, whenever iftar is (the punchline)</dd></div>
</dl>

<p>Here is the thread that got us there, translated and lightly compressed:</p>

<section class="chat-thread" aria-label="Slack thread: booking iftar at Apetit">
  <div class="chat-msg" style="--speaker-accent: oklch(60% 0.14 25);">
    <span class="chat-msg__avatar"><span>ak</span></span>
    <div class="chat-msg__body">
      <span class="chat-msg__name">Amina Kadrić</span>
      <p class="chat-msg__text"><span class="chat-mention">@here</span> Iftar in Sarajevo next week 🌙 React with the day that works — I'll find us a spot. 🍽️</p>
    </div>
  </div>

  <div class="chat-msg" style="--speaker-accent: oklch(60% 0.14 25);">
    <span class="chat-msg__avatar"><span>ak</span></span>
    <div class="chat-msg__body">
      <span class="chat-msg__name">Amina Kadrić</span>
      <p class="chat-msg__text"><span class="chat-mention">@nedim</span> <span class="chat-mention">@Aid</span> <span class="chat-mention">@Ahmed</span> <span class="chat-mention">@Adnan</span> — Booked Apetit for Tuesday. Pick a menu, ping me by tomorrow. 🙏</p>
    </div>
  </div>

  <div class="chat-msg" style="--speaker-accent: var(--color-pulse-600);">
    <span class="chat-msg__avatar"><span>nh</span></span>
    <div class="chat-msg__body">
      <span class="chat-msg__name">nedim</span>
      <p class="chat-msg__text">Hadžijski one for me. 🥩</p>
    </div>
  </div>

  <div class="chat-msg" style="--speaker-accent: oklch(70% 0.14 75);">
    <span class="chat-msg__avatar"><span>aa</span></span>
    <div class="chat-msg__body">
      <span class="chat-msg__name">Aid</span>
      <p class="chat-msg__text">Same. ➕</p>
    </div>
  </div>

  <div class="chat-msg" style="--speaker-accent: oklch(58% 0.13 145);">
    <span class="chat-msg__avatar"><span>am</span></span>
    <div class="chat-msg__body">
      <span class="chat-msg__name">Adnan</span>
      <p class="chat-msg__text">Number three, the baked chicken. 🍗</p>
    </div>
  </div>

  <div class="chat-msg" style="--speaker-accent: oklch(60% 0.13 200);">
    <span class="chat-msg__avatar"><span>ao</span></span>
    <div class="chat-msg__body">
      <span class="chat-msg__name">Ahmed</span>
      <p class="chat-msg__text">Same. Chicken. 🍗</p>
    </div>
  </div>

  <div class="chat-msg" style="--speaker-accent: var(--color-aurora-500);">
    <span class="chat-msg__avatar"><span>rd</span></span>
    <div class="chat-msg__body">
      <span class="chat-msg__name">Redzy</span>
      <p class="chat-msg__text">🍗 Same as those two ⬆️</p>
    </div>
  </div>

  <div class="chat-msg" style="--speaker-accent: oklch(60% 0.14 25);">
    <span class="chat-msg__avatar"><span>ak</span></span>
    <div class="chat-msg__body">
      <span class="chat-msg__name">Amina</span>
      <p class="chat-msg__text">Plot twist 🌀 — corporate menu needs 4+ people. Waiting on confirmation. What's your backup?</p>
    </div>
  </div>

  <div class="chat-msg" style="--speaker-accent: oklch(70% 0.14 75);">
    <span class="chat-msg__avatar"><span>aa</span></span>
    <div class="chat-msg__body">
      <span class="chat-msg__name">Aid</span>
      <p class="chat-msg__text"><strong>Svejedno.</strong> 🤷</p>
    </div>
  </div>

  <div class="chat-msg" style="--speaker-accent: var(--color-pulse-600);">
    <span class="chat-msg__avatar"><span>nh</span></span>
    <div class="chat-msg__body">
      <span class="chat-msg__name">nedim</span>
      <p class="chat-msg__text"><strong>Svejedno.</strong> Bump me to Menu 2. 🤷</p>
    </div>
  </div>

  <div class="chat-msg" style="--speaker-accent: oklch(60% 0.13 200);">
    <span class="chat-msg__avatar"><span>ao</span></span>
    <div class="chat-msg__body">
      <span class="chat-msg__name">Ahmed</span>
      <p class="chat-msg__text">I'll switch to corporate — but only if <span class="chat-mention">@nedim</span> takes my <strong>sahan</strong> 😄 <strong>Dolma</strong> and sarma, not my crowd.</p>
    </div>
  </div>

  <div class="chat-msg" style="--speaker-accent: oklch(60% 0.14 25);">
    <span class="chat-msg__avatar"><span>ak</span></span>
    <div class="chat-msg__body">
      <span class="chat-msg__name">Amina</span>
      <p class="chat-msg__text">Still short one 👀 I'm on fish or chicken.</p>
    </div>
  </div>

  <div class="chat-msg" style="--speaker-accent: var(--color-pulse-600);">
    <span class="chat-msg__avatar"><span>nh</span></span>
    <div class="chat-msg__body">
      <span class="chat-msg__name">nedim</span>
      <p class="chat-msg__text">Genuinely <strong>svejedno.</strong> Put whatever on my plate. 🍽️</p>
    </div>
  </div>

  <div class="chat-msg" style="--speaker-accent: oklch(60% 0.14 25);">
    <span class="chat-msg__avatar"><span>ak</span></span>
    <div class="chat-msg__body">
      <span class="chat-msg__name">Amina</span>
      <p class="chat-msg__text">I'll be late — client call till 5:30. You'll all beat me there. Reservation's under my name.</p>
    </div>
  </div>

  <div class="chat-msg" style="--speaker-accent: var(--color-pulse-600);">
    <span class="chat-msg__avatar"><span>nh</span></span>
    <div class="chat-msg__body">
      <span class="chat-msg__name">nedim</span>
      <p class="chat-msg__text">What time?</p>
    </div>
  </div>

  <div class="chat-msg" style="--speaker-accent: oklch(60% 0.14 25);">
    <span class="chat-msg__avatar"><span>ak</span></span>
    <div class="chat-msg__body">
      <span class="chat-msg__name">Amina</span>
      <p class="chat-msg__text">pa kad je iftar 😄 <span class="chat-msg__gloss-inline">(whenever iftar is)</span></p>
    </div>
  </div>
</section>

<p>A few things in that thread stayed with me.</p>

<h2 id="svejedno">"Svejedno."</h2>

<figure>
  <img src="/assets/images/posts/sarajevo-iftar-apetit/svejedno-pronunciation.svg" alt="How to say svejedno in English: sveh-YED-noh, stress on the middle syllable. Meaning: whatever, fine with me." loading="lazy" width="1600" height="900" />
  <figcaption>How to say it: <em>sveh-YED-noh</em>. Stress the middle.</figcaption>
</figure>

<p>Half the thread is people saying <em>svejedno</em>. In this team, that word means trust. Amina booked the restaurant, picked the menu options, chased the head-count. Nobody in the thread felt the need to second-guess any of it. When one of us takes on the coordination work, the rest of us get out of the way. And we take any excuse to sit at the same table — Clearview picks up the tab, and a distributed team never says no to a paid-for meal.</p>

<p>The same pattern shows up on client work. When someone picks up a task, the rest of the team stays out of the way. We ask when we have something to add, and we say <em>svejedno</em> — or the English equivalent, <em>"sounds good, go for it"</em> — when we don't.</p>

<p>Sometimes the person out front is the senior engineer on a client sprint. This time it was Amina, the one who knows every food spot in Sarajevo.</p>

<h2 id="pa-kad-je-iftar">"Pa kad je iftar."</h2>

<p>Amina's reply to my <em>what-time</em> question assumes that everyone at the table already knows when iftar is. A non-Bosnian reader would probably miss it.</p>

<p>Iftar is the meal Muslims eat to break the daily fast during the month of Ramadan. It happens at sunset, which means the exact time slides ten to fifteen minutes later every week as the days get longer. In Sarajevo in mid-March, sunset was somewhere around 6:15 pm. If you knew the calendar, you knew the time. If you did not, you asked, and Amina answered. Because she is the boss.</p>

<p>The cultural detail did not have to be spelled out in the thread. Everyone on the invite already got it.</p>

<h2 id="time-for-meza">Time for <em>meza</em></h2>

<p>The corporate menu did land in the end. The five of us who made it — Amina, Aid, Adnan, Ahmed, and me — ate at Apetit as the sun set behind the hills over the Miljacka. Redzy joined for part of it. There were the standard <em>meza</em>, the Bosnian word for the little starter plates other cuisines call <em>mezze</em> or <em>mezes</em>. In Bosnia, <em>meza</em> is the part of the meal where the real talking happens, where a table settles in and stops being in a hurry. Then came the mains, and then someone ordered <em>Bosanska kafa</em> and the conversation wandered, as it always does, into a mix of shop talk and family news.</p>

<p>Nobody talked about "team building."</p>

<figure>
  <img src="/assets/images/posts/sarajevo-iftar-apetit/dinner-02.jpg" alt="The Sarajevo Clearview team at the iftar table at Apetit." loading="lazy" width="1600" height="900" />
  <figcaption>Iftar at Apetit, Sarajevo, March 17, 2026.</figcaption>
</figure>

<p>We had not all sat at the same table since the last one, and we would probably not sit at the same table for another couple of months. That is the rhythm of a distributed team — long stretches of async work broken by a shared meal every few months.</p>

<h2 id="why-the-dinner-is-worth-it">Why the dinner is worth it</h2>

<p>For a distributed team, in-person meals matter more than they look. The grand summit in Bali counts — Clearview gathered in Ubud in January — but the small local dinner counts too, and it happens more often. The one where Amina's message goes out, five people RSVP within the day, and the reservation goes under her name because she is the one who called the restaurant. Typical Amina. She knows the best places to eat in Sarajevo, and the rest of us are smart enough to show up where she tells us to.</p>

<p>Every time we do one of these, the async work that follows for the next few weeks is smoother. Threaded replies come with more benefit of the doubt, less second-guessing, nobody a stranger. If you have never worked distributed, that is the reason to spend real money on getting people in a room. It makes the rest of the year work better.</p>

<p>We are lucky, in Sarajevo, that most of the Clearviewers here can be in the same city inside of an hour. Having enough of us in one city to fill a table on short notice is a real gift. When Ahmed messages that he doesn't like <em>dolma</em> and <em>sarma</em>, or Amina jokes that we should all know when iftar is, that only feels normal because we have eaten together before.</p>

<p class="post-dateline">Nedim &nbsp;·&nbsp; Sarajevo &nbsp;·&nbsp; March 2026</p>

<aside class="post-cta">
  <h2 id="come-eat-with-us-in-sarajevo">Come Eat With Us In Sarajevo</h2>

  <p>If we are working with you and you are ever passing through the
region — Vienna, Belgrade, Zagreb, Istanbul are all a short flight
or drive — send a note. <strong>Clearview Team</strong> clients occasionally
come through Sarajevo, and it changes the relationship. Meeting
people over <em>ćevapi</em> and <em>Bosanska kafa</em> is a different thing from
meeting them on Zoom for the first two years.</p>

  <p><a href="mailto:info@clearview.team?subject=Passing%20through%20Sarajevo">Say hi if you are in the neighbourhood →</a></p>
</aside>]]></content><author><name>Nedim Hadzimahmutovic</name></author><category term="remote" /><category term="sarajevo" /><category term="bosnia" /><category term="team" /><category term="culture" /><summary type="html"><![CDATA[From time to time, the Sarajevo Clearview team meets in the same room. "Same room" takes work and dedication. Most days, the team is stretched across the globe in apartments in Sarajevo, Malang, and Cape Town, with a laptop on US East Coast time for a client.]]></summary></entry><entry><title type="html">How a JWT Audience Map Saved a CORS Mistake: A Defense-in-Depth Case Study</title><link href="https://blog.clearview.team/2026/how-a-jwt-audience-map-saved-a-cors-mistake-defense-in-depth-case-study/" rel="alternate" type="text/html" title="How a JWT Audience Map Saved a CORS Mistake: A Defense-in-Depth Case Study" /><published>2026-07-22T11:00:00+02:00</published><updated>2026-07-22T11:00:00+02:00</updated><id>https://blog.clearview.team/2026/how-a-jwt-audience-map-saved-a-cors-mistake-defense-in-depth-case-study</id><content type="html" xml:base="https://blog.clearview.team/2026/how-a-jwt-audience-map-saved-a-cors-mistake-defense-in-depth-case-study/"><![CDATA[<p>While pen-testing a client's API, we found a CORS policy that accepted any subdomain of the client's main domain, with credentials, on every kind of HTTP request. Textbook misconfiguration. We were drafting it as a HIGH severity finding when we tried to run the exploit ourselves, and discovered a second, independent control that stopped the attack at a different layer.</p>

<p>Two terms before we go further:</p>

<ul>
  <li><strong>CORS</strong> — the browser's rule about which <em>other</em> websites are allowed to send authenticated requests to your API. Mis-set, it lets any site act on a logged-in user's behalf.</li>
  <li><strong>JWT</strong> — JSON Web Token. The short signed string the API hands a user after they log in. Every subsequent request carries it as proof of who the user is. The token has an <em>audience</em> field that names which application the token was minted for, and the API can refuse a token presented to the wrong audience.</li>
</ul>

<p>Two independent controls — the stricter one held while the weaker one was broken.</p>

<h2 id="the-cors-misconfiguration">The CORS Misconfiguration</h2>

<p>The Express CORS middleware allowed any subdomain through a regex:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// cors.loader.ts</span>
<span class="kd">const</span> <span class="nx">pattern</span> <span class="o">=</span> <span class="sr">/^https:</span><span class="se">\/\/([</span><span class="sr">a-z0-9-</span><span class="se">]</span><span class="sr">+</span><span class="se">\.)?</span><span class="sr">client</span><span class="se">\.</span><span class="sr">example</span><span class="se">\.</span><span class="sr">org$/</span><span class="p">;</span>
</code></pre></div></div>

<p>This matches any subdomain, including ones that do not exist yet, ones that could be registered by attackers, and ones that could be taken over via a dangling DNS record. We confirmed the breadth with a small matrix:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Evil subdomain — accepted</span>
curl <span class="nt">-sI</span> <span class="s2">"</span><span class="nv">$API</span><span class="s2">/api/config"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Origin: https://evil.client.example.org"</span>
<span class="c"># access-control-allow-origin: https://evil.client.example.org</span>
<span class="c"># access-control-allow-credentials: true</span>

<span class="c"># Punycode subdomain — accepted</span>
curl <span class="nt">-sI</span> <span class="s2">"</span><span class="nv">$API</span><span class="s2">/api/config"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Origin: https://xn--evil.client.example.org"</span>
<span class="c"># access-control-allow-origin: https://xn--evil.client.example.org</span>

<span class="c"># Numeric prefix — accepted</span>
curl <span class="nt">-sI</span> <span class="s2">"</span><span class="nv">$API</span><span class="s2">/api/config"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Origin: https://123evil.client.example.org"</span>
<span class="c"># access-control-allow-origin: https://123evil.client.example.org</span>
</code></pre></div></div>

<p>CORS with <code class="language-plaintext highlighter-rouge">credentials: true</code>, wildcard subdomains, and <code class="language-plaintext highlighter-rouge">DELETE</code> listed in the preflight <code class="language-plaintext highlighter-rouge">Access-Control-Allow-Methods</code>. Every box on the checklist of "trivially exploitable CORS misconfiguration."</p>

<p>We were about to write <em>HIGH — any subdomain can steal authenticated session data</em>. We tried to actually exploit it first.</p>

<h2 id="the-exploit-that-failed">The Exploit That Failed</h2>

<p>For the exploit to work in a real browser, the attacker needs to make authenticated requests from their evil subdomain. With JWT auth (Authorization header, not cookies), the attacker first has to obtain a token from the evil origin — either by signing in there, or by stealing a token from the legitimate frontend's <code class="language-plaintext highlighter-rouge">localStorage</code> and replaying it.</p>

<p>We tried signing in from the evil origin:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-s</span> <span class="nt">-X</span> POST <span class="s2">"</span><span class="nv">$API</span><span class="s2">/api/auth/signin"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Content-Type: application/json"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Origin: https://evil.client.example.org"</span> <span class="se">\</span>
  <span class="nt">-d</span> <span class="s1">'{"email":"test@example.org","password":"Secret123"}'</span>
</code></pre></div></div>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w"> </span><span class="nl">"success"</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span><span class="w"> </span><span class="nl">"message"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Unknown origin"</span><span class="w"> </span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>CORS <em>accepted</em> the origin. The API <em>rejected</em> the sign-in. Something independent of CORS was checking.</p>

<h2 id="the-jwt-audience-map">The JWT Audience Map</h2>

<p>Digging into <code class="language-plaintext highlighter-rouge">jwt.config.ts</code> surfaced this:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">audienceMap</span><span class="p">:</span> <span class="nb">Record</span><span class="o">&lt;</span><span class="kr">string</span><span class="p">,</span> <span class="kr">string</span><span class="o">&gt;</span> <span class="o">=</span> <span class="p">{</span>
  <span class="c1">// Production</span>
  <span class="dl">'</span><span class="s1">https://app.client.example</span><span class="dl">'</span><span class="p">:</span> <span class="dl">'</span><span class="s1">main-app</span><span class="dl">'</span><span class="p">,</span>
  <span class="dl">'</span><span class="s1">https://admin.client.example</span><span class="dl">'</span><span class="p">:</span>         <span class="dl">'</span><span class="s1">admin-app</span><span class="dl">'</span><span class="p">,</span>
  <span class="dl">'</span><span class="s1">https://partners.client.example</span><span class="dl">'</span><span class="p">:</span>  <span class="dl">'</span><span class="s1">partner-portal</span><span class="dl">'</span><span class="p">,</span>
  <span class="c1">// Staging</span>
  <span class="dl">'</span><span class="s1">https://app.staging.client.example</span><span class="dl">'</span><span class="p">:</span> <span class="dl">'</span><span class="s1">main-app</span><span class="dl">'</span><span class="p">,</span>
  <span class="dl">'</span><span class="s1">https://partners.staging.client.example</span><span class="dl">'</span><span class="p">:</span>  <span class="dl">'</span><span class="s1">partner-portal</span><span class="dl">'</span><span class="p">,</span>
  <span class="dl">'</span><span class="s1">https://admin.staging.client.example</span><span class="dl">'</span><span class="p">:</span>         <span class="dl">'</span><span class="s1">admin-app</span><span class="dl">'</span><span class="p">,</span>
  <span class="c1">// API-internal calls</span>
  <span class="dl">'</span><span class="s1">https://api.staging.client.example</span><span class="dl">'</span><span class="p">:</span> <span class="dl">'</span><span class="s1">api-internal</span><span class="dl">'</span><span class="p">,</span>
<span class="p">};</span>

<span class="k">export</span> <span class="kd">function</span> <span class="nf">resolveAudience</span><span class="p">(</span><span class="nx">origin</span><span class="p">:</span> <span class="kr">string</span> <span class="o">|</span> <span class="kc">undefined</span><span class="p">):</span> <span class="kr">string</span> <span class="o">|</span> <span class="kc">null</span> <span class="p">{</span>
  <span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">origin</span><span class="p">)</span> <span class="k">return</span> <span class="dl">'</span><span class="s1">api-internal</span><span class="dl">'</span><span class="p">;</span>
  <span class="k">return</span> <span class="nx">audienceMap</span><span class="p">[</span><span class="nx">origin</span><span class="p">]</span> <span class="o">??</span> <span class="kc">null</span><span class="p">;</span>  <span class="c1">// null = rejected</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Seven explicit origins. Anything else returns <code class="language-plaintext highlighter-rouge">null</code>, which the calling code translates into a <code class="language-plaintext highlighter-rouge">401 Unknown origin</code>. The CORS policy says "any subdomain is fine." The JWT system says "only these seven specific origins get tokens." Two access-control systems with conflicting policies — and the stricter one wins.</p>

<h2 id="testing-every-authenticated-path">Testing Every Authenticated Path</h2>

<p>We needed to know whether the audience check covers every authenticated operation or just sign-in:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 1. Get a token from a legitimate origin.</span>
<span class="nv">TOKEN</span><span class="o">=</span><span class="si">$(</span>curl <span class="nt">-s</span> <span class="nt">-X</span> POST <span class="s2">"</span><span class="nv">$API</span><span class="s2">/api/auth/signin"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Origin: https://partners.staging.client.example"</span> <span class="se">\</span>
  <span class="nt">-d</span> <span class="s1">'{"email":"test@example.org","password":"Secret123"}'</span> <span class="se">\</span>
  | jq <span class="nt">-r</span> <span class="s1">'.data.access_token'</span><span class="si">)</span>

<span class="c"># 2. Try the token from the evil origin.</span>
curl <span class="nt">-s</span> <span class="s2">"</span><span class="nv">$API</span><span class="s2">/api/auth/me"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Authorization: Bearer </span><span class="nv">$TOKEN</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Origin: https://evil.client.example.org"</span>
<span class="c"># → "Unknown origin"</span>

<span class="c"># 3. Try with no Origin header at all.</span>
curl <span class="nt">-s</span> <span class="s2">"</span><span class="nv">$API</span><span class="s2">/api/auth/me"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Authorization: Bearer </span><span class="nv">$TOKEN</span><span class="s2">"</span>
<span class="c"># → "Audience mismatch"</span>
<span class="c">#   (token has aud=partner-portal, no origin resolves to api-internal, mismatch)</span>

<span class="c"># 4. Try with a different legitimate origin.</span>
curl <span class="nt">-s</span> <span class="s2">"</span><span class="nv">$API</span><span class="s2">/api/auth/me"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Authorization: Bearer </span><span class="nv">$TOKEN</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Origin: https://app.staging.client.example"</span>
<span class="c"># → "Audience mismatch"</span>
<span class="c">#   (partner-portal ≠ main-app)</span>
</code></pre></div></div>

<p>Every path blocked. The audience check fires on every authenticated request, not just sign-in. A token minted for <code class="language-plaintext highlighter-rouge">partner-portal</code> only works when sent with the <code class="language-plaintext highlighter-rouge">partners.staging.client.example</code> Origin header — any other origin (evil, no-origin, different legitimate origin) returns 401.</p>

<h2 id="the-refresh-token-path">The Refresh Token Path</h2>

<p>Token refresh has the same check:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-s</span> <span class="nt">-X</span> POST <span class="s2">"</span><span class="nv">$API</span><span class="s2">/api/auth/refresh-token"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Origin: https://evil.client.example.org"</span> <span class="se">\</span>
  <span class="nt">-d</span> <span class="s2">"{</span><span class="se">\"</span><span class="s2">refresh_token</span><span class="se">\"</span><span class="s2">: </span><span class="se">\"</span><span class="nv">$REFRESH</span><span class="se">\"</span><span class="s2">}"</span>
<span class="c"># → "Unknown origin"</span>
</code></pre></div></div>

<p>The attacker cannot refresh a stolen token from an evil origin either.</p>

<h2 id="what-is-still-vulnerable">What Is Still Vulnerable</h2>

<p>The audience check only runs on authenticated endpoints. Public endpoints do not need it, and do not get it:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Public config — readable from any CORS-accepted origin.</span>
curl <span class="nt">-s</span> <span class="s2">"</span><span class="nv">$API</span><span class="s2">/api/config"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Origin: https://evil.client.example.org"</span>
<span class="c"># 200 OK — feature flags returned</span>

<span class="c"># Public catalog — same shape.</span>
curl <span class="nt">-s</span> <span class="s2">"</span><span class="nv">$API</span><span class="s2">/api/catalog/"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Origin: https://evil.client.example.org"</span>
<span class="c"># 200 OK</span>
</code></pre></div></div>

<p>Public data is readable from any origin that the CORS policy lets through. The data itself is not sensitive — feature flags and public catalog entries — so the impact is low. The client's product owner confirmed this explicitly.</p>

<p>The real remaining risk is XSS on a legitimate subdomain. If an attacker achieves XSS on, say, <code class="language-plaintext highlighter-rouge">partners.staging.client.example</code>, they can read the JWT out of <code class="language-plaintext highlighter-rouge">localStorage</code> and use it from script running on that legitimate origin. Neither control fires. The audience check sees a legitimate origin and CORS sees an approved one. The two controls only stop arbitrary origins, not legitimate origins compromised via a different bug.</p>

<h2 id="the-severity-downgrade">The Severity Downgrade</h2>

<p>We originally drafted the CORS wildcard as HIGH. After the exploitation attempt, we downgraded it to MEDIUM, with the residual risk path documented explicitly:</p>

<table>
  <thead>
    <tr>
      <th>Attack Scenario</th>
      <th>CORS</th>
      <th>JWT Audience</th>
      <th>Net Result</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Sign in from an evil subdomain</td>
      <td>allowed</td>
      <td><strong>blocked</strong></td>
      <td><strong>blocked</strong></td>
    </tr>
    <tr>
      <td>Use a stolen token from an evil origin</td>
      <td>allowed</td>
      <td><strong>blocked</strong></td>
      <td><strong>blocked</strong></td>
    </tr>
    <tr>
      <td>Refresh a stolen token from evil origin</td>
      <td>allowed</td>
      <td><strong>blocked</strong></td>
      <td><strong>blocked</strong></td>
    </tr>
    <tr>
      <td>Read public data from an evil subdomain</td>
      <td>allowed</td>
      <td>n/a</td>
      <td>succeeds (not sensitive)</td>
    </tr>
    <tr>
      <td>Token use from XSS'd legitimate origin</td>
      <td>allowed</td>
      <td>allowed</td>
      <td><strong>vulnerable</strong></td>
    </tr>
  </tbody>
</table>

<p>We still recommended the CORS fix, and it landed in the same sprint. Defense in depth means not relying on one independent control to do the work of two. Without the audience map, the CORS bug would have been trivially exploitable. With it, an attacker first needs script execution on a legitimate origin (XSS on <code class="language-plaintext highlighter-rouge">partners.staging.client.example</code> or similar), which is a much higher bar.</p>

<h2 id="why-two-independent-controls-worked">Why Two Independent Controls Worked</h2>

<p>The CORS policy and the JWT audience map were written at different times by different people for different reasons. CORS was set up early in the project, when the team was thinking about subdomain flexibility. The audience map was added later, when multi-portal authentication came in. Neither was deliberately a "backup" for the other.</p>

<p>The two controls do not need to know about each other. CORS asks whether the browser is allowed to send the request; the audience check asks whether the token is valid for this origin. Different questions, enforced in different places. When one is weaker, the other holds — which is what happened here.</p>

<aside class="callout">
  <p><strong>Separate but matched lists.</strong> The cleanest version of this system has the CORS allowlist and the JWT audience map sharing a single source-of-truth file. Drift between them is exactly the situation we walked into — CORS says yes, JWT says no, and the team is not sure which one defines the real attack surface. We unified the two on this engagement by reading both lists from the same <code class="language-plaintext highlighter-rouge">origins.config.ts</code> at boot.</p>
</aside>

<h2 id="two-things-this-engagement-changed-for-us">Two things this engagement changed for us</h2>

<p>First: try to exploit a finding end to end before assigning severity. The path from "this check is missing" to "I can steal user data" often has unexpected obstacles, and the severity should reflect the real impact, not the impact of the broken control in isolation.</p>

<p>Second: unify the CORS allowlist and the JWT audience list into one source-of-truth file, two readers. When the two lists drift, defense in depth becomes defense in name only. On this engagement, we consolidated both into a single <code class="language-plaintext highlighter-rouge">origins.config.ts</code> at boot; the CORS middleware and the audience resolver now read from the same list.</p>

<p>The residual risk (XSS on a legitimate subdomain) went into the report explicitly, because a layered defense that holds against arbitrary origins still needs the reader to know what would open the attack.</p>

<aside class="post-cta">
  <h2 id="we-could-run-this-pass-for-your-team">We Could Run This Pass For Your Team</h2>

  <p>If your API has CORS and JWT both in the request path, and you are not certain whether the two layers reinforce each other or disagree, we have walked into a few of those. <strong>Clearview Team</strong> can audit yours in a sprint — you walk away with severity ratings that reflect what an attacker could actually pull off, and the fixes go in alongside the report.</p>

  <p><a href="mailto:info@clearview.team?subject=API%20defense-in-depth%20pen-test%20enquiry">Scope an engagement →</a></p>
</aside>]]></content><author><name>Nedim Hadzimahmutovic</name></author><category term="jwt" /><category term="cors" /><category term="defense-in-depth" /><category term="security" /><category term="devsecops" /><category term="api-security" /><category term="express" /><category term="web-api-security" /><category term="auth-architecture" /><category term="case-study" /><summary type="html"><![CDATA[While pen-testing a client's API, we found a CORS policy that accepted any subdomain of the client's main domain, with credentials, on every kind of HTTP request. Textbook misconfiguration. We were drafting it as a HIGH severity finding when we tried to run the exploit ourselves, and discovered a second, independent control that stopped the attack at a different layer.]]></summary></entry><entry><title type="html">Asking For Notification Permission: The Second Time Is the Wrong Time</title><link href="https://blog.clearview.team/2026/notification-permission-priming/" rel="alternate" type="text/html" title="Asking For Notification Permission: The Second Time Is the Wrong Time" /><published>2026-07-14T11:00:00+02:00</published><updated>2026-07-14T11:00:00+02:00</updated><id>https://blog.clearview.team/2026/notification-permission-priming</id><content type="html" xml:base="https://blog.clearview.team/2026/notification-permission-priming/"><![CDATA[<p>I work on a B2C health app where notifications aren't a growth channel, they're half the product. The user sets a sleep goal, the app reminds them to wind down before bedtime. They pick a protocol with a daily action, the app nudges them to do it. Without notification permission all of that just doesn't exist for the user, they set goals in onboarding and then nothing ever follows up on them.</p>

<p>Our first onboarding didn't treat the permission with that much respect. We did it the way most apps do, at some point in the flow we called the API, the iOS dialog popped up with no context, and the user had to decide on the spot why an app they installed five minutes ago wants to send them things. Predictably, a lot of them tapped "Don't Allow".</p>

<p>We assumed we could ask again later, once the user had seen enough of the app to change their mind. That assumption was wrong, and it's the reason this post exists. On iOS the native prompt is a one-shot, once the user says no, calling <code class="language-plaintext highlighter-rouge">requestPermissionsAsync()</code> again just returns "denied" without showing anything. The only recovery is the user going to Settings, Notifications, Your App and flipping the switch by hand, and very few people who decline ever do that.</p>

<p>So every "Don't Allow" was a permanent opt-out from a core part of the product, which made the decline rate the most expensive metric in the whole onboarding.</p>

<p>Let me show you what we did instead, the screen we put in front of the prompt, the hook that guards the request, and what we're still measuring.</p>

<h2 id="what-the-ios-docs-dont-tell-you">What the iOS docs don't tell you</h2>

<p>The native prompt is a one-shot, that part is documented. What isn't documented as prominently is when it becomes a one-shot. It's not the first time you call <code class="language-plaintext highlighter-rouge">requestPermissionsAsync()</code>, it's the first time the user sees it. Once they've seen the prompt and declined, you can't show it again from your app code.</p>

<p>You can detect the state with <code class="language-plaintext highlighter-rouge">getPermissionsAsync()</code>:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="o">*</span> <span class="kd">as </span><span class="nx">Notifications</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">expo-notifications</span><span class="dl">"</span><span class="p">;</span>

<span class="kd">const</span> <span class="p">{</span> <span class="nx">status</span> <span class="p">}</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">Notifications</span><span class="p">.</span><span class="nf">getPermissionsAsync</span><span class="p">();</span>
<span class="c1">// "granted" | "denied" | "undetermined"</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">"undetermined"</code> is the only state where calling <code class="language-plaintext highlighter-rouge">requestPermissionsAsync()</code> will actually show the system prompt. <code class="language-plaintext highlighter-rouge">"denied"</code> means the user already saw it and said no, and your only recovery is the Settings deep link. <code class="language-plaintext highlighter-rouge">"granted"</code> means you're clear to send notifications.</p>

<p>The whole pattern in this post is built on one rule: never call request while the status is undetermined unless you're confident the user will say yes.</p>

<h2 id="timing-a-lesson-from-game-design">Timing: a lesson from game design</h2>

<p>Mobile games solved this years ago. A well-designed game never asks for notification permission on first launch, it waits until you've finished the first level, earned your first reward, or started a building that takes an hour to complete. At that moment the notification has an obvious job, "we'll tell you when it's done", and the player has already felt the value, so the ask answers a question they actually have.</p>

<p>The worst time to ask is before the user has experienced anything, at that point the permission prompt is a cost with no visible benefit and "Don't Allow" is the rational answer. The best time is right after a moment of value, when the notification is clearly in service of something the user just chose.</p>

<p>That's why our screen sits where it does in the onboarding. The user has already entered their name, picked their goals and invested a few minutes in the flow, so the notification ask is framed as the thing that makes those goals happen. Same principle as the game asking after the first level, earn the moment first, then ask.</p>

<h2 id="the-screen-we-show-before-the-prompt">The screen we show before the prompt</h2>

<p>The screen we shipped is one step in the onboarding flow, right after the user has entered their name, picked their goals and invested a few minutes in the flow. It's deliberately simple, a title, a short description and two buttons:</p>

<div class="language-tsx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">&lt;</span><span class="nc">NotificationExplainerScreen</span>
  <span class="na">title</span><span class="p">=</span><span class="s">"Stay on track"</span>
  <span class="na">description</span><span class="p">=</span><span class="s">"We'll send you gentle reminders for your daily habits, lab result updates the moment they're ready, and progress milestones worth celebrating. One nudge at the right time, never spam. You're in control, turn off or customize every notification type anytime in Settings."</span>
  <span class="na">primaryAction</span><span class="p">=</span><span class="si">{</span><span class="p">{</span>
    <span class="na">label</span><span class="p">:</span> <span class="dl">"</span><span class="s2">Turn on notifications</span><span class="dl">"</span><span class="p">,</span>
    <span class="na">onPress</span><span class="p">:</span> <span class="nx">requestNotificationPermission</span><span class="p">,</span>
  <span class="p">}</span><span class="si">}</span>
  <span class="na">secondaryAction</span><span class="p">=</span><span class="si">{</span><span class="p">{</span> <span class="na">label</span><span class="p">:</span> <span class="dl">"</span><span class="s2">Skip for now</span><span class="dl">"</span><span class="p">,</span> <span class="na">onPress</span><span class="p">:</span> <span class="nx">advanceOnboarding</span> <span class="p">}</span><span class="si">}</span>
<span class="p">/&gt;</span>
</code></pre></div></div>

<p>Every line of that copy is doing a job, and most of the jobs come straight from game design.</p>

<p>"Gentle reminders for your daily habits" names the concrete thing the user just set up, the goals from two screens ago, so the notification is in service of something they chose. "Progress milestones worth celebrating" is the reward loop, games learned a long time ago that people say yes to notifications about wins, a level completed, a streak kept, much more readily than to notifications about obligations. "Lab result updates the moment they're ready" gives the notification an obvious job, the same way a game asks for permission when your building has an hour left, we'll tell you when it's done.</p>

<p>And "you're in control, turn off or customize anytime in Settings" lowers the perceived cost of saying yes. Good games always let you toggle notification types individually, and telling the user up front that they keep that control makes "Turn on notifications" feel reversible instead of a commitment.</p>

<p>Then there's "Skip for now", and this one needs explaining because it looks like a mistake. It isn't. Skipping our screen costs nothing, the permission status stays undetermined, so we can ask again later at a better moment, after the user's first completed habit or their first lab result. Declining the OS prompt costs everything, it burns the one-shot permanently. So the skip button is there on purpose, we'd much rather collect a cheap "not now" on our own screen than push an unsure user into an expensive, irreversible "Don't Allow" on Apple's.</p>

<h2 id="the-hook">The hook</h2>

<p>The screen calls a single hook on continue:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// hooks/useNotificationSetup.ts — abridged</span>
<span class="k">import</span> <span class="o">*</span> <span class="kd">as </span><span class="nx">Notifications</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">expo-notifications</span><span class="dl">"</span><span class="p">;</span>

<span class="k">export</span> <span class="kd">function</span> <span class="nf">useNotificationSetup</span><span class="p">()</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">requestNotificationPermission</span> <span class="o">=</span> <span class="nf">useCallback</span><span class="p">(</span><span class="k">async </span><span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">current</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">Notifications</span><span class="p">.</span><span class="nf">getPermissionsAsync</span><span class="p">();</span>

    <span class="k">if </span><span class="p">(</span><span class="nx">current</span><span class="p">.</span><span class="nx">status</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">granted</span><span class="dl">"</span><span class="p">)</span> <span class="p">{</span>
      <span class="c1">// Already on. Skip the prompt; advance the onboarding.</span>
      <span class="k">return</span> <span class="dl">"</span><span class="s2">granted</span><span class="dl">"</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="k">if </span><span class="p">(</span><span class="nx">current</span><span class="p">.</span><span class="nx">status</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">denied</span><span class="dl">"</span><span class="p">)</span> <span class="p">{</span>
      <span class="c1">// Already declined. Show the "open Settings" sheet instead of the prompt.</span>
      <span class="nf">showSettingsSheet</span><span class="p">();</span>
      <span class="k">return</span> <span class="dl">"</span><span class="s2">denied</span><span class="dl">"</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="c1">// status === "undetermined" — the one case where the prompt will actually fire.</span>
    <span class="kd">const</span> <span class="nx">result</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">Notifications</span><span class="p">.</span><span class="nf">requestPermissionsAsync</span><span class="p">({</span>
      <span class="na">ios</span><span class="p">:</span> <span class="p">{</span>
        <span class="na">allowAlert</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
        <span class="na">allowBadge</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
        <span class="na">allowSound</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
        <span class="na">allowAnnouncements</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
      <span class="p">},</span>
    <span class="p">});</span>

    <span class="k">if </span><span class="p">(</span><span class="nx">result</span><span class="p">.</span><span class="nx">status</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">granted</span><span class="dl">"</span><span class="p">)</span> <span class="p">{</span>
      <span class="k">await</span> <span class="nf">registerForPushToken</span><span class="p">();</span>
    <span class="p">}</span>

    <span class="k">return</span> <span class="nx">result</span><span class="p">.</span><span class="nx">status</span><span class="p">;</span>
  <span class="p">},</span> <span class="p">[]);</span>

  <span class="k">return</span> <span class="p">{</span> <span class="nx">requestNotificationPermission</span> <span class="p">};</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Three guards in three lines. "Granted" skips the prompt entirely, "denied" opens the Settings deep-link sheet so the user can recover, and "undetermined" is the only path that calls the OS request.</p>

<p>Asking for the four iOS flags explicitly is worth doing. We didn't enable <code class="language-plaintext highlighter-rouge">allowAnnouncements</code>, that's the Siri "read this aloud through AirPods" permission and the product doesn't need it. Asking for permissions you don't need just nudges the user toward decline.</p>

<h2 id="the-settings-sheet-for-the-users-who-already-said-no">The Settings sheet, for the users who already said no</h2>

<p>We show a separate small sheet for users whose status is already <code class="language-plaintext highlighter-rouge">"denied"</code>:</p>

<div class="language-tsx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">&lt;</span><span class="nc">BottomSheet</span>
  <span class="na">title</span><span class="p">=</span><span class="s">"Notifications are off"</span>
  <span class="na">body</span><span class="p">=</span><span class="s">"You said no the first time, which is fine. The only way to turn them on now is in your phone's Settings."</span>
  <span class="na">primaryAction</span><span class="p">=</span><span class="si">{</span><span class="p">{</span>
    <span class="na">label</span><span class="p">:</span> <span class="dl">"</span><span class="s2">Open Settings</span><span class="dl">"</span><span class="p">,</span>
    <span class="na">onPress</span><span class="p">:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="nx">Linking</span><span class="p">.</span><span class="nf">openSettings</span><span class="p">(),</span>
  <span class="p">}</span><span class="si">}</span>
  <span class="na">secondaryAction</span><span class="p">=</span><span class="si">{</span><span class="p">{</span> <span class="na">label</span><span class="p">:</span> <span class="dl">"</span><span class="s2">Maybe later</span><span class="dl">"</span><span class="p">,</span> <span class="na">onPress</span><span class="p">:</span> <span class="nx">dismiss</span> <span class="p">}</span><span class="si">}</span>
<span class="p">/&gt;</span>
</code></pre></div></div>

<p>The copy matters here. "You said no the first time, which is fine" respects the user's earlier choice, and "the only way to turn them on now is in your phone's Settings" is the honest mechanic, not a sales pitch. The share of declined users who open Settings from this sheet is meaningfully higher than the share who responded to the previous "Notifications are off, enable them" banner inside the app shell.</p>

<h2 id="the-measurement">The measurement</h2>

<p>We're tracking three numbers, weekly.</p>

<p><strong>Our screen's tap rate.</strong> What share of users who land on our screen tap "Turn on notifications".</p>

<p><strong>Permission grant rate after the CTA.</strong> Of the users who tap our CTA, how many then tap "Allow" on the system prompt that follows.</p>

<p><strong>Net grant rate.</strong> Our screen's tap rate times the system-prompt grant rate. The lift over the bare system-prompt baseline is the metric the product team watches, and on this product it's meaningful, meaningful enough that showing our own screen first is now the model for every new permission ask, not just notifications.</p>

<h2 id="what-i-would-tell-another-engineer-setting-this-up">What I would tell another engineer setting this up</h2>

<ol>
  <li><strong>Never call <code class="language-plaintext highlighter-rouge">requestPermissionsAsync</code> without checking the status first.</strong> The one-shot rule on iOS is unforgiving, the three-branch guard is one of those patterns where the lines you don't call matter more than the ones you do.</li>
  <li><strong>Write the screen copy around concrete, positive jobs.</strong> Habit reminders the user just set up, lab results the moment they're ready, milestones worth celebrating. Specific and rewarding beats "stay engaged with the app", the same reason game notifications lead with wins, not obligations.</li>
  <li><strong>Let users skip your screen, never let them skip into the OS prompt.</strong> A "Skip for now" on your own screen is cheap, the status stays undetermined and you can ask again at a better moment. A "Don't Allow" on the system prompt is permanent. Design the screen so the unsure user takes the cheap no.</li>
  <li><strong>Settings deep link for the declined cohort.</strong> Most users who declined the first time won't come back, some will, so make the recovery a one-tap path.</li>
  <li><strong>Ask for the iOS flags you actually need.</strong> <code class="language-plaintext highlighter-rouge">allowAnnouncements</code> isn't free, every permission you ask for raises the bar to "yes".</li>
</ol>

<aside class="post-cta">
  <h2 id="we-could-reshape-your-permission-flow">We Could Reshape Your Permission Flow</h2>

  <p>If your app is hitting the system permission prompt cold and a meaningful share of your onboarding cohort is declining, that's one sprint of work that pays for itself in feature reach. <strong>Clearview Team</strong> has shipped this exact flow on a B2C app and the grant rate is trending well ahead of the bare-prompt baseline. We can design the screen copy, write the three-branch hook and wire the Settings recovery path on iOS and Android. Bring the product, we'll bring the flow.</p>

  <p><a href="mailto:info@clearview.team?subject=Notification%20permission%20enquiry">Brief us on your permission flow →</a></p>
</aside>]]></content><author><name>Amar Spahic</name></author><category term="react-native" /><category term="mobile" /><category term="ios" /><category term="android" /><category term="ux" /><category term="notifications" /><category term="frontend-mobile" /><category term="case-study" /><summary type="html"><![CDATA[I work on a B2C health app where notifications aren't a growth channel, they're half the product. The user sets a sleep goal, the app reminds them to wind down before bedtime. They pick a protocol with a daily action, the app nudges them to do it. Without notification permission all of that just doesn't exist for the user, they set goals in onboarding and then nothing ever follows up on them.]]></summary></entry></feed>