<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>Nedim Hadzimahmutovic · Remote Since Forever</title>
  <subtitle>Essays by Nedim Hadzimahmutovic on Remote Since Forever — the Clearview Team blog.</subtitle>
  <link href="https://blog.clearview.team/authors/nedim-hadzimahmutovic/feed.xml" rel="self" type="application/atom+xml" />
  <link href="https://blog.clearview.team/" />
  <updated>2026-09-18T09:57:51+02:00</updated>
  <id>https://blog.clearview.team/authors/nedim-hadzimahmutovic/feed.xml</id>
  <author>
    <name>Nedim Hadzimahmutovic</name>
  </author>
  <entry>
    <title>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/" />
    <id>https://blog.clearview.team/2026/closing-the-rate-limit-bypass-cloudfront-alb-case-study/</id>
    <published>2026-09-14T11:00:00+02:00</published>
    <updated>2026-09-14T11:00:00+02:00</updated>
    <author>
      <name>Nedim Hadzimahmutovic</name>
    </author>
    <summary>How we closed an `X-Forwarded-For` spoofing bypass on an AWS WAF rate-limit rule — CloudFront custom secret header, ALB origin verification, and a composite-key rate-based rule that actually counts real clients.</summary>
    <content type="html">&lt;p&gt;Three weeks before a client&apos;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.&lt;/p&gt;

&lt;p&gt;One line of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;curl&lt;/code&gt; 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:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;curl &lt;span class=&quot;nt&quot;&gt;-H&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;X-Forwarded-For: 203.0.113.7&quot;&lt;/span&gt; https://api.client.example/auth/signin
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

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

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

&lt;h2 id=&quot;the-client-architecture&quot;&gt;The Client Architecture&lt;/h2&gt;

&lt;p&gt;The intended request path looked like this:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Client → CloudFront → ALB → Express (Node.js)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;What each of those four boxes actually is:&lt;/p&gt;

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

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

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

&lt;p&gt;Express needs the real client IP for rate limiting. That is what &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;trust proxy&lt;/code&gt; exists for:&lt;/p&gt;

&lt;div class=&quot;language-typescript highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;// express.ts:12&lt;/span&gt;
&lt;span class=&quot;nx&quot;&gt;app&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;set&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;trust proxy&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;2&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

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

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

&lt;p&gt;Express reads the real client IP. The rate limiter counts per IP. Everything works — until you skip the proxy chain entirely.&lt;/p&gt;

&lt;h2 id=&quot;what-we-caught&quot;&gt;What We Caught&lt;/h2&gt;

&lt;p&gt;The first signal was the ALB responding directly:&lt;/p&gt;

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

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;server: awselb/2.0&lt;/code&gt; with no &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;x-cache&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;via&lt;/code&gt; 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.&lt;/p&gt;

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

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

&lt;p&gt;We confirmed the rate limiter worked normally first:&lt;/p&gt;

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

&lt;p&gt;Then with rotating fake IPs:&lt;/p&gt;

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

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

&lt;p&gt;Brute force went through unimpeded. We reproduced it in staging and in production.&lt;/p&gt;

&lt;h2 id=&quot;why-the-alb-was-reachable-at-all&quot;&gt;Why the ALB Was Reachable At All&lt;/h2&gt;

&lt;p&gt;The ALB security group was supposed to restrict inbound traffic to CloudFront only. The intended rule was present:&lt;/p&gt;

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

&lt;p&gt;But three other rules were &lt;em&gt;also&lt;/em&gt; present, left over from an earlier migration:&lt;/p&gt;

&lt;div class=&quot;language-hcl highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nx&quot;&gt;ingress&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;from_port&lt;/span&gt;   &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;80&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;to_port&lt;/span&gt;     &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;80&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;protocol&lt;/span&gt;    &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;tcp&quot;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;cidr_blocks&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;0.0.0.0/0&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;nx&quot;&gt;ingress&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;from_port&lt;/span&gt;   &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;443&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;to_port&lt;/span&gt;     &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;443&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;protocol&lt;/span&gt;    &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;tcp&quot;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;cidr_blocks&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;0.0.0.0/0&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;nx&quot;&gt;ingress&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;from_port&lt;/span&gt;   &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;444&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;to_port&lt;/span&gt;     &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;444&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;protocol&lt;/span&gt;    &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;tcp&quot;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;cidr_blocks&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;0.0.0.0/0&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

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

&lt;h2 id=&quot;the-fix-isnt-dropping-trust-proxy&quot;&gt;The Fix Isn&apos;t Dropping &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;trust proxy&lt;/code&gt;&lt;/h2&gt;

&lt;p&gt;The reflex when a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;trust proxy: 2&lt;/code&gt; system gets bypassed is to drop the number:&lt;/p&gt;

&lt;div class=&quot;language-typescript highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nx&quot;&gt;app&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;set&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;trust proxy&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;// We did NOT do this.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;That breaks the rest of the stack. With &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;trust proxy: 1&lt;/code&gt; and legitimate traffic going through CloudFront + ALB (two real hops), Express would skip only one proxy and read &lt;strong&gt;CloudFront&apos;s edge IP&lt;/strong&gt; 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.&lt;/p&gt;

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

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

&lt;span class=&quot;c1&quot;&gt;# Remove every 0.0.0.0/0 ingress rule.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

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

&lt;h2 id=&quot;defense-in-depth--what-we-added-alongside&quot;&gt;Defense in Depth — What We Added Alongside&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;aside class=&quot;callout&quot;&gt;
  &lt;p&gt;&lt;strong&gt;One bad security-group edit away.&lt;/strong&gt; 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.&lt;/p&gt;
&lt;/aside&gt;

&lt;h3 id=&quot;1-cloudfront-custom-header-validation-at-the-app-layer&quot;&gt;1. CloudFront custom-header validation at the app layer&lt;/h3&gt;

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

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

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

&lt;p&gt;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.&lt;/p&gt;

&lt;h3 id=&quot;2-account-based-rate-limiting-on-auth-endpoints&quot;&gt;2. Account-based rate limiting on auth endpoints&lt;/h3&gt;

&lt;p&gt;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 &lt;strong&gt;email address&lt;/strong&gt; on the sign-in endpoint:&lt;/p&gt;

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

&lt;p&gt;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 &quot;too many attempts&quot; response and an account-recovery email instead of a silent brute force in the background.&lt;/p&gt;

&lt;h2 id=&quot;what-we-shipped-back&quot;&gt;What We Shipped Back&lt;/h2&gt;

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

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

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

&lt;h2 id=&quot;the-checklist-we-carry-forward&quot;&gt;The Checklist We Carry Forward&lt;/h2&gt;

&lt;p&gt;Five checks we now run on every Express + ALB + CloudFront engagement.&lt;/p&gt;

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

&lt;h2 id=&quot;how-to-check-your-own-setup&quot;&gt;How To Check Your Own Setup&lt;/h2&gt;

&lt;p&gt;If you are running the same shape — Express behind CloudFront and an ALB — three commands tell you whether you have the same gap:&lt;/p&gt;

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

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

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

&lt;h2 id=&quot;common-aws-waf-rate-limit-questions&quot;&gt;Common AWS WAF rate-limit questions&lt;/h2&gt;

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

&lt;h3 id=&quot;how-do-i-rate-limit-per-hostname-on-aws-waf&quot;&gt;How do I rate-limit per hostname on AWS WAF?&lt;/h3&gt;

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

&lt;h3 id=&quot;can-aws-waf-rate-limit-on-a-1-minute-window-instead-of-5&quot;&gt;Can AWS WAF rate-limit on a 1-minute window instead of 5?&lt;/h3&gt;

&lt;p&gt;Not directly — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RateBasedStatement&lt;/code&gt; supports a fixed set of
evaluation windows (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;60&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;120&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;300&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;600&lt;/code&gt; seconds as of 2026).
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;EvaluationWindowSec: 60&lt;/code&gt; 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.&lt;/p&gt;

&lt;h3 id=&quot;how-do-i-rate-limit-by-url-path-segment&quot;&gt;How do I rate-limit by URL path segment?&lt;/h3&gt;

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

&lt;h3 id=&quot;how-do-i-exclude-a-known-good-ip-from-an-aws-waf-rate-rule&quot;&gt;How do I exclude a known good IP from an AWS WAF rate rule?&lt;/h3&gt;

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

&lt;aside class=&quot;post-cta&quot;&gt;
  &lt;h2 id=&quot;we-could-look-at-yours&quot;&gt;We Could Look At Yours&lt;/h2&gt;

  &lt;p&gt;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 &lt;strong&gt;Clearview Team&lt;/strong&gt;, the same engineer reads the diff every sprint, runs the same &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;curl&lt;/code&gt; matrix before every release, and the fix usually lands in the same sprint as the finding.&lt;/p&gt;

  &lt;p&gt;&lt;a href=&quot;/work-with-us/&quot;&gt;Scope an engagement →&lt;/a&gt;&lt;/p&gt;
&lt;/aside&gt;
</content>
    <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" />
    
  </entry>
  
  <entry>
    <title>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/" />
    <id>https://blog.clearview.team/2026/replacing-a-wildcard-cors-policy-an-express-allowlist-case-study/</id>
    <published>2026-08-31T11:00:00+02:00</published>
    <updated>2026-08-31T11:00:00+02:00</updated>
    <author>
      <name>Nedim Hadzimahmutovic</name>
    </author>
    <summary>How we replaced a permissive Express `cors` regex with a strict allowlist — escaped-dot origin matching, `Vary: Origin` on every response, and no more CWE-942 finding on the pen-test report.</summary>
    <content type="html">&lt;p&gt;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.&lt;/p&gt;

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

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

&lt;p&gt;The regex was the problem.&lt;/p&gt;

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

&lt;h2 id=&quot;the-configuration-we-found&quot;&gt;The Configuration We Found&lt;/h2&gt;

&lt;p&gt;The CORS middleware was wired up like this:&lt;/p&gt;

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

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

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

&lt;h2 id=&quot;how-we-demonstrated-the-problem&quot;&gt;How We Demonstrated the Problem&lt;/h2&gt;

&lt;p&gt;A quick test from the curl side:&lt;/p&gt;

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

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

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

&lt;p&gt;CORS with credentials means the browser is authorising the cross-origin destination to act on the user&apos;s behalf with their session.&lt;/p&gt;

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

&lt;p&gt;The legitimate user is on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;app.client.com&lt;/code&gt;. The malicious code is on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;evil.client.com&lt;/code&gt;. Both match the regex. The browser sends the user&apos;s session cookies along. The API returns the profile data. The attacker exfiltrates it.&lt;/p&gt;

&lt;h2 id=&quot;the-preflight-was-also-verbose&quot;&gt;The Preflight Was Also Verbose&lt;/h2&gt;

&lt;p&gt;While running the test matrix, we noticed the preflight response was generous:&lt;/p&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;h2 id=&quot;why-this-happens--the-regex-trap&quot;&gt;Why This Happens — The Regex Trap&lt;/h2&gt;

&lt;p&gt;The regex is technically correct for the stated intent: &quot;match any subdomain of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;client.com&lt;/code&gt;.&quot; The trap is that &quot;any subdomain&quot; includes subdomains that&lt;/p&gt;

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

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

&lt;h2 id=&quot;the-fix-we-shipped--an-explicit-allowlist&quot;&gt;The Fix We Shipped — An Explicit Allowlist&lt;/h2&gt;

&lt;p&gt;We replaced the regex with a list of strings:&lt;/p&gt;

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

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

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

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

&lt;p&gt;Three things to note about the callback form:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;callback(null, false)&lt;/code&gt; is the rejection path&lt;/strong&gt; — the middleware returns a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;403&lt;/code&gt; instead of letting an exception bubble up to a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;500&lt;/code&gt;. The application no longer leaks the existence of the endpoint to non-allowlisted origins.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The empty-origin case is explicit.&lt;/strong&gt; 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.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;credentials: true&lt;/code&gt; flag is still on&lt;/strong&gt; — that is correct &lt;em&gt;because&lt;/em&gt; the allowlist is now strict.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id=&quot;what-we-had-to-fix-alongside-cors&quot;&gt;What We Had To Fix Alongside CORS&lt;/h2&gt;

&lt;p&gt;The middleware previously threw an Error on unknown origins:&lt;/p&gt;

&lt;div class=&quot;language-typescript highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;// Previously&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;if &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;isAllowed&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;throw&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;Error&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;Not allowed by CORS&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

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

&lt;h2 id=&quot;the-feature-branch-conversation&quot;&gt;The Feature-Branch Conversation&lt;/h2&gt;

&lt;p&gt;The frontend team&apos;s first response was the one we expect on every engagement that touches CORS:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&quot;But we need dynamic subdomains for feature branches. We cannot maintain a list.&quot;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The list is not maintained by hand — it is maintained by the deployment pipeline.&lt;/p&gt;

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

&lt;p&gt;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 &lt;em&gt;any&lt;/em&gt; subdomain.&lt;/p&gt;

&lt;h2 id=&quot;verifying-after-the-change&quot;&gt;Verifying After the Change&lt;/h2&gt;

&lt;p&gt;The reproduction script we ran before the fix returns clean results afterwards:&lt;/p&gt;

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

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

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

&lt;p&gt;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.&lt;/p&gt;

&lt;h2 id=&quot;what-we-added-alongside-cors--the-layered-story&quot;&gt;What We Added Alongside CORS — The Layered Story&lt;/h2&gt;

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

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Allowed methods narrowed per endpoint.&lt;/strong&gt; The middleware no longer advertises &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DELETE&lt;/code&gt; on endpoints that do not support it. Each route declares its own method set.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;JWT validation on every authenticated request.&lt;/strong&gt; A separate audience check — covered in &lt;a href=&quot;/2026/how-a-jwt-audience-map-saved-a-cors-mistake-defense-in-depth-case-study/&quot;&gt;its own case study&lt;/a&gt; — runs alongside CORS. Even on the days CORS gets misconfigured again, the audience check holds.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Origin telemetry.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id=&quot;five-things-we-now-check-on-every-cors-engagement&quot;&gt;Five things we now check on every CORS engagement&lt;/h2&gt;

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

&lt;aside class=&quot;post-cta&quot;&gt;
  &lt;h2 id=&quot;we-could-audit-your-cors&quot;&gt;We Could Audit Your CORS&lt;/h2&gt;

  &lt;p&gt;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. &lt;strong&gt;Clearview Team&lt;/strong&gt; 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.&lt;/p&gt;

  &lt;p&gt;&lt;a href=&quot;/work-with-us/&quot;&gt;Scope an engagement →&lt;/a&gt;&lt;/p&gt;
&lt;/aside&gt;
</content>
    <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" />
    
  </entry>
  
  <entry>
    <title>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/" />
    <id>https://blog.clearview.team/2026/cutting-the-file-upload-bill-cognito-s3-case-study/</id>
    <published>2026-08-24T11:00:00+02:00</published>
    <updated>2026-08-24T11:00:00+02:00</updated>
    <author>
      <name>Nedim Hadzimahmutovic</name>
    </author>
    <summary>How we replaced a hosted upload widget with native Cognito and S3, kept the browser uploading straight to the bucket, and brought the monthly invoice down.</summary>
    <content type="html">&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;strong&gt;Cognito + S3&lt;/strong&gt; setup. We built a three-layer permission model that most Cognito-on-S3 tutorials skip, which is why they leak.&lt;/p&gt;

&lt;p&gt;Two AWS services do the work here, both worth one sentence each before we go further:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;S3&lt;/strong&gt; — AWS&apos;s object-storage service. The bucket the files actually land in. Cheap, durable, billed per gigabyte stored and per request.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Cognito Identity Pool&lt;/strong&gt; — AWS&apos;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 &quot;boundary&quot; the rest of this post tightens.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;what-the-frontend-had-to-do&quot;&gt;What the Frontend Had to Do&lt;/h2&gt;

&lt;p&gt;The browser uploads files directly to storage — never through the backend API. This pattern matters for two reasons:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Throughput.&lt;/strong&gt; A 200 MB upload doesn&apos;t tie up a backend worker for two minutes.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Cost.&lt;/strong&gt; Backend egress through the API doubles the bandwidth bill.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;What we needed to replicate:&lt;/p&gt;

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

&lt;h2 id=&quot;the-architecture&quot;&gt;The Architecture&lt;/h2&gt;

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

&lt;p&gt;The flow on a happy path:&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/images/posts/cutting-the-file-upload-bill-cognito-s3-case-study/sequence.svg&quot; alt=&quot;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.&quot; /&gt;&lt;/p&gt;

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

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

&lt;h2 id=&quot;the-three-layers-of-permission-evaluation&quot;&gt;The Three Layers of Permission Evaluation&lt;/h2&gt;

&lt;p&gt;This is the mental model we built before writing a single line of Terraform.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/images/posts/cutting-the-file-upload-bill-cognito-s3-case-study/three-layers.svg&quot; alt=&quot;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).&quot; /&gt;&lt;/p&gt;

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

&lt;p&gt;The non-obvious behavior:&lt;/p&gt;

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

&lt;p&gt;Print it out.&lt;/p&gt;

&lt;h2 id=&quot;the-terraform--identity-pool&quot;&gt;The Terraform — Identity Pool&lt;/h2&gt;

&lt;div class=&quot;language-hcl highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nx&quot;&gt;resource&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;aws_cognito_identity_pool&quot;&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;uploads&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;identity_pool_name&lt;/span&gt;               &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;client-uploads&quot;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;allow_unauthenticated_identities&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;nx&quot;&gt;resource&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;aws_cognito_identity_pool_roles_attachment&quot;&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;uploads&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;identity_pool_id&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;aws_cognito_identity_pool&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;uploads&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;id&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;roles&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;s2&quot;&gt;&quot;unauthenticated&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;aws_iam_role&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;cognito_unauth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;arn&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;We &lt;em&gt;do&lt;/em&gt; allow unauthenticated identities, because the client&apos;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.&lt;/p&gt;

&lt;h2 id=&quot;the-terraform--iam-role&quot;&gt;The Terraform — IAM Role&lt;/h2&gt;

&lt;p&gt;The IAM role for the unauthenticated identity gets a minimal trust policy and a permission policy that grants only &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;s3:PutObject&lt;/code&gt; 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.&lt;/p&gt;

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

&lt;span class=&quot;nx&quot;&gt;resource&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;aws_iam_role&quot;&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;cognito_unauth&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;name&lt;/span&gt;               &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;cognito-uploads-unauth&quot;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;assume_role_policy&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;aws_iam_policy_document&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;cognito_unauth_assume&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;json&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

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

&lt;span class=&quot;nx&quot;&gt;resource&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;aws_iam_role_policy&quot;&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;cognito_unauth&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;role&lt;/span&gt;   &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;aws_iam_role&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;cognito_unauth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;id&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;policy&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;aws_iam_policy_document&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;cognito_unauth_perms&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;json&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Three constraints, all important:&lt;/p&gt;

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

&lt;h2 id=&quot;the-terraform--bucket-and-bucket-policy&quot;&gt;The Terraform — Bucket and Bucket Policy&lt;/h2&gt;

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

&lt;div class=&quot;language-hcl highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nx&quot;&gt;resource&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;aws_s3_bucket&quot;&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;uploads&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;bucket&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;client-uploads-prod&quot;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;nx&quot;&gt;resource&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;aws_s3_bucket_public_access_block&quot;&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;uploads&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;bucket&lt;/span&gt;                  &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;aws_s3_bucket&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;uploads&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;id&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;block_public_acls&lt;/span&gt;       &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;block_public_policy&lt;/span&gt;     &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;ignore_public_acls&lt;/span&gt;      &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;restrict_public_buckets&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;nx&quot;&gt;resource&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;aws_s3_bucket_versioning&quot;&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;uploads&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;bucket&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;aws_s3_bucket&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;uploads&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;id&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;versioning_configuration&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;status&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Enabled&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

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

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

&lt;span class=&quot;nx&quot;&gt;resource&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;aws_s3_bucket_policy&quot;&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;uploads&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;bucket&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;aws_s3_bucket&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;uploads&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;id&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;policy&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;aws_iam_policy_document&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;uploads_bucket_policy&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;json&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

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

&lt;aside class=&quot;callout&quot;&gt;
  &lt;p&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;s3:content-type&lt;/code&gt; is IAM-only.&lt;/strong&gt; It is a valid condition key in IAM policies but &lt;strong&gt;not&lt;/strong&gt; in bucket policies. AWS will reject a bucket policy that tries to use it with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MalformedPolicy&lt;/code&gt;. The IAM-side check is the one that fires when temporary credentials present a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Content-Type&lt;/code&gt; 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 &lt;em&gt;authenticated&lt;/em&gt; identities, and is a defense-in-depth layer if you later move to the basic auth flow.&lt;/p&gt;
&lt;/aside&gt;

&lt;h2 id=&quot;verifying-with-curl&quot;&gt;Verifying With &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;curl&lt;/code&gt;&lt;/h2&gt;

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

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

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

&lt;p&gt;We then exported the credentials and ran the matrix of operations our policy is supposed to block:&lt;/p&gt;

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

&lt;span class=&quot;c&quot;&gt;# Listing — denied&lt;/span&gt;
aws s3 &lt;span class=&quot;nb&quot;&gt;ls &lt;/span&gt;s3://client-uploads-prod/
&lt;span class=&quot;c&quot;&gt;# An error occurred (AccessDenied) ...&lt;/span&gt;

&lt;span class=&quot;c&quot;&gt;# Deletes — denied&lt;/span&gt;
aws s3 &lt;span class=&quot;nb&quot;&gt;rm &lt;/span&gt;s3://client-uploads-prod/uploads/foo/bar.jpg
&lt;span class=&quot;c&quot;&gt;# An error occurred (AccessDenied) ...&lt;/span&gt;

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

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

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

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

&lt;h2 id=&quot;what-we-added-back-from-the-managed-service&quot;&gt;What We Added Back From the Managed Service&lt;/h2&gt;

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

&lt;h3 id=&quot;1-file-size-limits-and-content-type-validation-properly&quot;&gt;1. File-size limits and content-type validation, properly&lt;/h3&gt;

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

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

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

&lt;h3 id=&quot;2-anti-abuse-for-guest-uploads&quot;&gt;2. Anti-abuse for guest uploads&lt;/h3&gt;

&lt;p&gt;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 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GetCredentialsForIdentity&lt;/code&gt;. This converts &quot;anyone can get AWS credentials&quot; into &quot;any verified human can get AWS credentials&quot; — the bar we wanted.&lt;/p&gt;

&lt;h3 id=&quot;3-bucket-abuse-limits&quot;&gt;3. Bucket abuse limits&lt;/h3&gt;

&lt;p&gt;We layered in two things the managed service used to do for us:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;S3 Lifecycle rule&lt;/strong&gt; that deletes anything in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;uploads/$sub/&lt;/code&gt; older than 30 days unless the object has been promoted (a tag set when the user completes signup).&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;CloudWatch alarm&lt;/strong&gt; on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BucketSizeBytes&lt;/code&gt; and on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NumberOfObjects&lt;/code&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;migration-plan-no-downtime&quot;&gt;Migration Plan (No Downtime)&lt;/h2&gt;

&lt;p&gt;We rolled this out in three phases over two sprints. The client never lost an upload.&lt;/p&gt;

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

&lt;h2 id=&quot;what-the-client-walked-away-with&quot;&gt;What the Client Walked Away With&lt;/h2&gt;

&lt;p&gt;In rough numbers (exact figures live in the client&apos;s invoice records, not ours):&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;The monthly bill dropped sharply.&lt;/strong&gt; Most of the savings came from the dominant cost shifting from &quot;you pay per upload&quot; to &quot;you pay per gigabyte stored&quot; — and on this workload, per-GB storage on S3 is a fraction of what the managed service was charging per request.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;They own the pipeline now.&lt;/strong&gt; The whole upload pipeline lives in their own Terraform repository. If they ever want to swap a piece out, they can.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;They can do things the managed service did not let them do.&lt;/strong&gt; 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.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The boundary is tighter than the tutorial version.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The client&apos;s reaction, paraphrased: &lt;em&gt;&quot;This is the architecture we wished the vendor&apos;s docs had explained in the first place.&quot;&lt;/em&gt;&lt;/p&gt;

&lt;h2 id=&quot;two-gotchas-the-tutorials-skip&quot;&gt;Two gotchas the tutorials skip&lt;/h2&gt;

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

&lt;p&gt;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 &quot;what did the temporary credential actually do&quot; is available at all.&lt;/p&gt;

&lt;aside class=&quot;post-cta&quot;&gt;
  &lt;h2 id=&quot;we-could-run-this-migration-for-you&quot;&gt;We Could Run This Migration For You&lt;/h2&gt;

  &lt;p&gt;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. &lt;strong&gt;Clearview Team&lt;/strong&gt; 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.&lt;/p&gt;

  &lt;p&gt;&lt;a href=&quot;mailto:info@clearview.team?subject=Cognito%20S3%20upload%20migration%20enquiry&quot;&gt;Scope an engagement →&lt;/a&gt;&lt;/p&gt;
&lt;/aside&gt;
</content>
    <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" />
    
  </entry>
  
  <entry>
    <title>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/" />
    <id>https://blog.clearview.team/2026/cloudfront-error-page-jwt-leak-case-study/</id>
    <published>2026-08-13T11:00:00+02:00</published>
    <updated>2026-08-13T11:00:00+02:00</updated>
    <author>
      <name>Nedim Hadzimahmutovic</name>
    </author>
    <summary>A pen-test curl found CloudFront forwarding `Authorization` headers to the S3 error-page bucket, where S3 reflected every JWT back in its XML error response. Two lines of Terraform closed it across sixteen distributions.</summary>
    <content type="html">&lt;p&gt;A client was preparing to put an API behind CloudFront in production. During the final penetration test, one &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;curl&lt;/code&gt; request showed that every distribution with custom error pages was reflecting users&apos; JWT tokens in S3 XML error responses.&lt;/p&gt;

&lt;p&gt;Sixteen distributions shared the same copy-pasted Terraform block.&lt;/p&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;h2 id=&quot;the-one-line-proof&quot;&gt;The One-Line Proof&lt;/h2&gt;

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

&lt;p&gt;If your response looks like this, you&apos;re vulnerable:&lt;/p&gt;

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

&lt;p&gt;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 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Authorization&lt;/code&gt; header triggers it, no authentication and no origin failure required.&lt;/p&gt;

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

&lt;p&gt;Cookies, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;X-API-Key&lt;/code&gt;, 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.&lt;/p&gt;

&lt;h2 id=&quot;why-did-cloudfront-forward-the-authorization-header-to-s3&quot;&gt;Why did CloudFront forward the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Authorization&lt;/code&gt; header to S3?&lt;/h2&gt;

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

&lt;p&gt;In this pattern, there are two origins behind one distribution:&lt;/p&gt;

&lt;figure class=&quot;post-figure--wide&quot;&gt;
  &lt;img src=&quot;/assets/images/posts/cloudfront-error-page-jwt-leak-case-study/topology.svg&quot; alt=&quot;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.&quot; loading=&quot;lazy&quot; /&gt;
  &lt;figcaption&gt;The API behavior needs viewer headers. The S3 error-page behavior does not.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;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&apos;s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Authorization&lt;/code&gt; header to authenticate requests, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Content-Type&lt;/code&gt; to parse bodies, and cookies for sessions. Forwarding everything to this origin is correct.&lt;/p&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Authorization: Bearer &amp;lt;JWT&amp;gt;&lt;/code&gt;.&lt;/p&gt;

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

&lt;p&gt;The setup looks like this in Terraform (CloudFormation and the console follow the same pattern):&lt;/p&gt;

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

&lt;span class=&quot;c1&quot;&gt;# Origin 2: S3 bucket with static error pages&lt;/span&gt;
&lt;span class=&quot;nx&quot;&gt;origin&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;domain_name&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;aws_s3_bucket&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;error_pages&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;website_endpoint&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;origin_id&lt;/span&gt;   &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;error-pages&quot;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# When the API returns 502, serve the S3 error page instead&lt;/span&gt;
&lt;span class=&quot;nx&quot;&gt;custom_error_response&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;error_code&lt;/span&gt;         &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;502&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;response_page_path&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;/custom_error_pages/502.html&quot;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

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

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

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

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

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

&lt;h2 id=&quot;three-ways-the-token-leaks&quot;&gt;Three ways the token leaks&lt;/h2&gt;

&lt;p&gt;The error page path isn&apos;t linked anywhere. No browser navigates to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/custom_error_pages/502.html&lt;/code&gt; during normal use. Three practical routes still reach it.&lt;/p&gt;

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

&lt;p&gt;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&apos;s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Authorization&lt;/code&gt; header attached. The user didn&apos;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.&lt;/p&gt;

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

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

&lt;h2 id=&quot;the-terraform-template-problem&quot;&gt;The Terraform Template Problem&lt;/h2&gt;

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

&lt;p&gt;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 &quot;worked&quot; and nobody questioned whether a static error page bucket needed to receive the viewer&apos;s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Authorization&lt;/code&gt; header.&lt;/p&gt;

&lt;p&gt;Terraform&apos;s copy-paste turned one mistake into a pattern across the whole account.&lt;/p&gt;

&lt;p&gt;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&apos;s JWT. The rest served static frontends that don&apos;t normally receive &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Authorization&lt;/code&gt; headers, but the direct-access path still worked on all of them.&lt;/p&gt;

&lt;h2 id=&quot;the-chain-that-made-it-critical&quot;&gt;The Chain That Made It Critical&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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&apos;s plaintext environment block. Not in Secrets Manager, not pulled from SSM Parameter Store, just a short, dictionary-word string sitting in Terraform.&lt;/p&gt;

&lt;p&gt;We confirmed the secret remotely without any internal access. The API returns different error messages depending on whether a token&apos;s signature verifies:&lt;/p&gt;

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

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

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

&lt;h3 id=&quot;we-proved-the-full-chain-end-to-end&quot;&gt;We proved the full chain end-to-end&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Sign in.&lt;/strong&gt; 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.&lt;/p&gt;

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

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

&lt;p&gt;&lt;strong&gt;Step 4: Replay.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 5: Account takeover.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;figure class=&quot;post-figure--wide&quot;&gt;
  &lt;img src=&quot;/assets/images/posts/cloudfront-error-page-jwt-leak-case-study/attack-chain.svg&quot; alt=&quot;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.&quot; loading=&quot;lazy&quot; /&gt;
  &lt;figcaption&gt;The leak becomes account takeover when the staging JWT secret is guessable.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;We ran the entire chain with curl against a live staging environment. We didn&apos;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.&lt;/p&gt;

&lt;h2 id=&quot;the-public-bucket&quot;&gt;The public bucket&lt;/h2&gt;

&lt;p&gt;The bucket is also enumerable:&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;curl https://your-error-pages-bucket.s3.amazonaws.com/
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Returns a full &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ListBucketResult&lt;/code&gt; XML with every file, size, ETag, and last-modified timestamp, no authentication required.&lt;/p&gt;

&lt;p&gt;It&apos;s public because the error page origin uses an S3 &lt;strong&gt;website endpoint&lt;/strong&gt;, 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.&lt;/p&gt;

&lt;h2 id=&quot;the-fix&quot;&gt;The Fix&lt;/h2&gt;

&lt;p&gt;The error page behavior serves static HTML from S3. It doesn&apos;t need the viewer&apos;s auth token, cookies, query strings, or any other header.&lt;/p&gt;

&lt;p&gt;When you omit &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;origin_request_policy_id&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;The simplest fix is to delete the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;origin_request_policy_id&lt;/code&gt; line from every error page behavior. It shouldn&apos;t have been there in the first place.&lt;/p&gt;

&lt;p&gt;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:&lt;/p&gt;

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

  &lt;span class=&quot;nx&quot;&gt;headers_config&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;header_behavior&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;none&quot;&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;cookies_config&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;cookie_behavior&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;none&quot;&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;query_strings_config&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;query_string_behavior&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;none&quot;&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Then in every &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ordered_cache_behavior&lt;/code&gt; for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/custom_error_pages/*&lt;/code&gt;:&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Verification:&lt;/strong&gt;&lt;/p&gt;

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

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

&lt;p&gt;For defense in depth:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;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.&lt;/li&gt;
  &lt;li&gt;Set &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;error_caching_min_ttl = 0&lt;/code&gt; so error responses aren&apos;t cached at all.&lt;/li&gt;
  &lt;li&gt;Set &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;viewer_protocol_policy = &quot;redirect-to-https&quot;&lt;/code&gt; on the error page behavior so the path can&apos;t be accessed over plain HTTP.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Forwarding headers to error pages &lt;em&gt;is&lt;/em&gt; valid when the origin needs the viewer&apos;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 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;header_behavior = &quot;none&quot;&lt;/code&gt;.&lt;/p&gt;

&lt;h2 id=&quot;what-we-shipped&quot;&gt;What we shipped&lt;/h2&gt;

&lt;p&gt;Sixteen distributions closed. The JWT reflection path is gone across the client&apos;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.&lt;/p&gt;

&lt;h2 id=&quot;how-to-check-if-youre-vulnerable&quot;&gt;How to Check If You&apos;re Vulnerable&lt;/h2&gt;

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

&lt;p&gt;&lt;strong&gt;Step 2: Send a request with an auth header.&lt;/strong&gt;&lt;/p&gt;

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

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

&lt;p&gt;&lt;strong&gt;Step 4: Check all your distributions.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h2 id=&quot;check-every-distribution&quot;&gt;Check every distribution&lt;/h2&gt;

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

&lt;p&gt;&lt;em&gt;A note on responsible testing: every test account created during this engagement was registered with a non-existent &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@example.com&lt;/code&gt; 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.&lt;/em&gt;&lt;/p&gt;

&lt;aside class=&quot;post-cta&quot;&gt;
  &lt;h2 id=&quot;we-could-scan-your-cloudfront-for-this&quot;&gt;We Could Scan Your CloudFront For This&lt;/h2&gt;

  &lt;p&gt;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. &lt;strong&gt;Clearview Team&lt;/strong&gt; 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.&lt;/p&gt;

  &lt;p&gt;&lt;a href=&quot;mailto:info@clearview.team?subject=CloudFront%20audit%20enquiry&quot;&gt;Scope a CloudFront audit →&lt;/a&gt;&lt;/p&gt;
&lt;/aside&gt;
</content>
    <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" />
    
  </entry>
  
  <entry>
    <title>Iftar at Apetit: The Sarajevo Team Picks a Menu</title>
    <link href="https://blog.clearview.team/2026/sarajevo-iftar-apetit/" />
    <id>https://blog.clearview.team/2026/sarajevo-iftar-apetit/</id>
    <published>2026-08-04T11:00:00+02:00</published>
    <updated>2026-08-04T11:00:00+02:00</updated>
    <author>
      <name>Nedim Hadzimahmutovic</name>
    </author>
    <summary>One evening in March the Sarajevo Clearviewers sat down for a Ramadan iftar dinner. The Slack thread that got us to the table is the honest picture of how the team actually organizes itself: informal, patient, and mostly saying &apos;svejedno.&apos;</summary>
    <content type="html">&lt;p&gt;From time to time, the Sarajevo Clearview team meets in the same room. &quot;Same room&quot; 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.&lt;/p&gt;

&lt;p&gt;The week before Ramadan, as it is the company&apos;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 &lt;em&gt;Vils&lt;/em&gt;, five minutes from most of our apartments.&lt;/p&gt;

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

&lt;p&gt;Here is the thread that got us there, translated and lightly compressed:&lt;/p&gt;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;p&gt;A few things in that thread stayed with me.&lt;/p&gt;

&lt;h2 id=&quot;svejedno&quot;&gt;&quot;Svejedno.&quot;&lt;/h2&gt;

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

&lt;p&gt;Half the thread is people saying &lt;em&gt;svejedno&lt;/em&gt;. 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.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;svejedno&lt;/em&gt; — or the English equivalent, &lt;em&gt;&quot;sounds good, go for it&quot;&lt;/em&gt; — when we don&apos;t.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2 id=&quot;pa-kad-je-iftar&quot;&gt;&quot;Pa kad je iftar.&quot;&lt;/h2&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The cultural detail did not have to be spelled out in the thread. Everyone on the invite already got it.&lt;/p&gt;

&lt;h2 id=&quot;time-for-meza&quot;&gt;Time for &lt;em&gt;meza&lt;/em&gt;&lt;/h2&gt;

&lt;p&gt;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 &lt;em&gt;meza&lt;/em&gt;, the Bosnian word for the little starter plates other cuisines call &lt;em&gt;mezze&lt;/em&gt; or &lt;em&gt;mezes&lt;/em&gt;. In Bosnia, &lt;em&gt;meza&lt;/em&gt; 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 &lt;em&gt;Bosanska kafa&lt;/em&gt; and the conversation wandered, as it always does, into a mix of shop talk and family news.&lt;/p&gt;

&lt;p&gt;Nobody talked about &quot;team building.&quot;&lt;/p&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;h2 id=&quot;why-the-dinner-is-worth-it&quot;&gt;Why the dinner is worth it&lt;/h2&gt;

&lt;p&gt;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&apos;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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&apos;t like &lt;em&gt;dolma&lt;/em&gt; and &lt;em&gt;sarma&lt;/em&gt;, or Amina jokes that we should all know when iftar is, that only feels normal because we have eaten together before.&lt;/p&gt;

&lt;p class=&quot;post-dateline&quot;&gt;Nedim &amp;nbsp;·&amp;nbsp; Sarajevo &amp;nbsp;·&amp;nbsp; March 2026&lt;/p&gt;

&lt;aside class=&quot;post-cta&quot;&gt;
  &lt;h2 id=&quot;come-eat-with-us-in-sarajevo&quot;&gt;Come Eat With Us In Sarajevo&lt;/h2&gt;

  &lt;p&gt;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. &lt;strong&gt;Clearview Team&lt;/strong&gt; clients occasionally
come through Sarajevo, and it changes the relationship. Meeting
people over &lt;em&gt;ćevapi&lt;/em&gt; and &lt;em&gt;Bosanska kafa&lt;/em&gt; is a different thing from
meeting them on Zoom for the first two years.&lt;/p&gt;

  &lt;p&gt;&lt;a href=&quot;mailto:info@clearview.team?subject=Passing%20through%20Sarajevo&quot;&gt;Say hi if you are in the neighbourhood →&lt;/a&gt;&lt;/p&gt;
&lt;/aside&gt;
</content>
    <category term="remote" />
    <category term="sarajevo" />
    <category term="bosnia" />
    <category term="team" />
    <category term="culture" />
    
  </entry>
  
  <entry>
    <title>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/" />
    <id>https://blog.clearview.team/2026/how-a-jwt-audience-map-saved-a-cors-mistake-defense-in-depth-case-study/</id>
    <published>2026-07-22T11:00:00+02:00</published>
    <updated>2026-07-22T11:00:00+02:00</updated>
    <author>
      <name>Nedim Hadzimahmutovic</name>
    </author>
    <summary>On a client&apos;s API, a CORS wildcard looked like a HIGH severity finding — until we tried to actually exploit it. A second, independent JWT audience check turned a trivially exploitable bug into one that required a much harder prerequisite.</summary>
    <content type="html">&lt;p&gt;While pen-testing a client&apos;s API, we found a CORS policy that accepted any subdomain of the client&apos;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.&lt;/p&gt;

&lt;p&gt;Two terms before we go further:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;CORS&lt;/strong&gt; — the browser&apos;s rule about which &lt;em&gt;other&lt;/em&gt; websites are allowed to send authenticated requests to your API. Mis-set, it lets any site act on a logged-in user&apos;s behalf.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;JWT&lt;/strong&gt; — 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 &lt;em&gt;audience&lt;/em&gt; field that names which application the token was minted for, and the API can refuse a token presented to the wrong audience.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two independent controls — the stricter one held while the weaker one was broken.&lt;/p&gt;

&lt;h2 id=&quot;the-cors-misconfiguration&quot;&gt;The CORS Misconfiguration&lt;/h2&gt;

&lt;p&gt;The Express CORS middleware allowed any subdomain through a regex:&lt;/p&gt;

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

&lt;p&gt;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:&lt;/p&gt;

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

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

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

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

&lt;p&gt;We were about to write &lt;em&gt;HIGH — any subdomain can steal authenticated session data&lt;/em&gt;. We tried to actually exploit it first.&lt;/p&gt;

&lt;h2 id=&quot;the-exploit-that-failed&quot;&gt;The Exploit That Failed&lt;/h2&gt;

&lt;p&gt;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&apos;s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;localStorage&lt;/code&gt; and replaying it.&lt;/p&gt;

&lt;p&gt;We tried signing in from the evil origin:&lt;/p&gt;

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

&lt;div class=&quot;language-json highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;success&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;kc&quot;&gt;false&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;message&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;Unknown origin&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;CORS &lt;em&gt;accepted&lt;/em&gt; the origin. The API &lt;em&gt;rejected&lt;/em&gt; the sign-in. Something independent of CORS was checking.&lt;/p&gt;

&lt;h2 id=&quot;the-jwt-audience-map&quot;&gt;The JWT Audience Map&lt;/h2&gt;

&lt;p&gt;Digging into &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;jwt.config.ts&lt;/code&gt; surfaced this:&lt;/p&gt;

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

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

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

&lt;h2 id=&quot;testing-every-authenticated-path&quot;&gt;Testing Every Authenticated Path&lt;/h2&gt;

&lt;p&gt;We needed to know whether the audience check covers every authenticated operation or just sign-in:&lt;/p&gt;

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

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

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

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

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

&lt;h2 id=&quot;the-refresh-token-path&quot;&gt;The Refresh Token Path&lt;/h2&gt;

&lt;p&gt;Token refresh has the same check:&lt;/p&gt;

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

&lt;p&gt;The attacker cannot refresh a stolen token from an evil origin either.&lt;/p&gt;

&lt;h2 id=&quot;what-is-still-vulnerable&quot;&gt;What Is Still Vulnerable&lt;/h2&gt;

&lt;p&gt;The audience check only runs on authenticated endpoints. Public endpoints do not need it, and do not get it:&lt;/p&gt;

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

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

&lt;p&gt;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&apos;s product owner confirmed this explicitly.&lt;/p&gt;

&lt;p&gt;The real remaining risk is XSS on a legitimate subdomain. If an attacker achieves XSS on, say, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;partners.staging.client.example&lt;/code&gt;, they can read the JWT out of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;localStorage&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2 id=&quot;the-severity-downgrade&quot;&gt;The Severity Downgrade&lt;/h2&gt;

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

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

&lt;p&gt;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 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;partners.staging.client.example&lt;/code&gt; or similar), which is a much higher bar.&lt;/p&gt;

&lt;h2 id=&quot;why-two-independent-controls-worked&quot;&gt;Why Two Independent Controls Worked&lt;/h2&gt;

&lt;p&gt;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 &quot;backup&quot; for the other.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;aside class=&quot;callout&quot;&gt;
  &lt;p&gt;&lt;strong&gt;Separate but matched lists.&lt;/strong&gt; 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 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;origins.config.ts&lt;/code&gt; at boot.&lt;/p&gt;
&lt;/aside&gt;

&lt;h2 id=&quot;two-things-this-engagement-changed-for-us&quot;&gt;Two things this engagement changed for us&lt;/h2&gt;

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

&lt;p&gt;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 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;origins.config.ts&lt;/code&gt; at boot; the CORS middleware and the audience resolver now read from the same list.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;aside class=&quot;post-cta&quot;&gt;
  &lt;h2 id=&quot;we-could-run-this-pass-for-your-team&quot;&gt;We Could Run This Pass For Your Team&lt;/h2&gt;

  &lt;p&gt;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. &lt;strong&gt;Clearview Team&lt;/strong&gt; 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.&lt;/p&gt;

  &lt;p&gt;&lt;a href=&quot;mailto:info@clearview.team?subject=API%20defense-in-depth%20pen-test%20enquiry&quot;&gt;Scope an engagement →&lt;/a&gt;&lt;/p&gt;
&lt;/aside&gt;
</content>
    <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" />
    
  </entry>
  
  <entry>
    <title>From DevOps to DevSecOps: How I Became Clearview&apos;s In-House Pen-Tester</title>
    <link href="https://blog.clearview.team/2026/from-devops-to-devsecops-why-i-started-breaking-things/" />
    <id>https://blog.clearview.team/2026/from-devops-to-devsecops-why-i-started-breaking-things/</id>
    <published>2026-07-11T11:00:00+02:00</published>
    <updated>2026-07-11T11:00:00+02:00</updated>
    <author>
      <name>Nedim Hadzimahmutovic</name>
    </author>
    <summary>How we built an in-sprint pen-tester role into every engagement — the DevSecOps handoff, the two decades of DevOps that led to it, and the OWASP Top 10 categories that show up first in real client APIs.</summary>
    <content type="html">&lt;p&gt;It took me a decade of shipping things reliably before I started spending my nights trying to break them.&lt;/p&gt;

&lt;p&gt;For a long time I was the person who shipped it. CI/CD pipelines, Terraform modules, the monitoring stack, the &quot;why is staging down?&quot; Slack messages at midnight. I have been doing that work for nearly two decades, even before it was called DevOps.&lt;/p&gt;

&lt;p&gt;Earlier this year I took on a different job inside Clearview Team. Now, before any new feature ships, somebody spends a day trying to break it. Often, that somebody is me.&lt;/p&gt;

&lt;h2 id=&quot;the-night-the-job-description-changed&quot;&gt;The Night the Job Description Changed&lt;/h2&gt;

&lt;p&gt;It was March 2026. I could not sleep. Too much coffee, too many open browser tabs, and a Hacker News thread I could not stop reading about a breach that turned out to be a misconfiguration — a public-read S3 bucket exposing millions of records.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;&quot;That could never happen to us,&quot;&lt;/em&gt; I thought. &lt;em&gt;&quot;We have policies. We check these things.&quot;&lt;/em&gt; Then I got up, opened my laptop, and started checking anyway.&lt;/p&gt;

&lt;p&gt;Four hours later I had found three public S3 buckets in our own infrastructure. None of them held customer data — they held logs and config files, enough information for an attacker to draw a map of the system.&lt;/p&gt;

&lt;p&gt;The Terraform that created those buckets had been written in the early days, before we had standards, before we had a review process, before a single &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;checkov&lt;/code&gt; rule ran in the pipeline. Nobody had ever gone back. They had &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;terraform apply&lt;/code&gt;-ed once and moved on.&lt;/p&gt;

&lt;p&gt;Those buckets were not somebody else&apos;s mistake. They were my Terraform, my missing review process. Finding them was less a security win than a critique of how I had been working for years.&lt;/p&gt;

&lt;p&gt;I fixed them that morning, wrote up the incident, told the team. And then I could not stop.&lt;/p&gt;

&lt;h2 id=&quot;what-an-engineer-reads-at-night-when-they-cannot-sleep&quot;&gt;What an Engineer Reads at Night When They Cannot Sleep&lt;/h2&gt;

&lt;p&gt;I started spending evenings on a different reading list:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;JWT vulnerabilities&lt;/strong&gt; — specifically how stateless tokens make revocation almost impossible&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;SQL injection patterns that still work in 2026&lt;/strong&gt; — parameterised queries handle the textbook case, but ORMs like TypeORM ship their own foot-guns (raw queries, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;addSelect&lt;/code&gt; on relations, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;QueryBuilder&lt;/code&gt; with string interpolation)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;CORS bypasses&lt;/strong&gt; — wildcard subdomain matching, reflected origins, preflight caching abuse&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;What &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;trust proxy&lt;/code&gt; actually does in Express&lt;/strong&gt; — and why getting the hop count wrong means your rate limiter trusts attacker-supplied IPs, which matters a great deal if you are sitting behind CloudFront and an ALB&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I am not claiming to be a research-level expert in any of these. I am claiming &lt;em&gt;curiosity&lt;/em&gt;. I went deep enough on each to spot the same patterns in our own codebases and to ask sharper questions during reviews. That is different from being a full pen tester, and I try not to confuse the two.&lt;/p&gt;

&lt;p&gt;Slowly, I started looking at our code differently. Every endpoint became a question about what happens when somebody sends weird input; every configuration became a suspicion about whether it was actually secure or only looked secure.&lt;/p&gt;

&lt;p&gt;I was running &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;curl&lt;/code&gt; against staging at strange hours, creating test invoices, reading the error messages line by line, mapping out what existed. I was becoming paranoid, in a mostly-good way.&lt;/p&gt;

&lt;p&gt;The hard part about paranoia is knowing when to put it down. I have lost sleep over things that turned out to be fine. I have filed false positives with embarrassing confidence — I once shipped a critical report about a writable S3 bucket because I had tested with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;aws s3 cp --dryrun&lt;/code&gt;, which validates syntax but not IAM. The actual upload failed with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AccessDenied&lt;/code&gt;. I had to correct my own report the next morning.&lt;/p&gt;

&lt;p&gt;Paranoia is useful, but it needs guardrails: a checklist, a second opinion, the willingness to say &lt;em&gt;I was wrong&lt;/em&gt; out loud and in writing.&lt;/p&gt;

&lt;h2 id=&quot;what-tools-catch-and-what-they-miss&quot;&gt;What Tools Catch and What They Miss&lt;/h2&gt;

&lt;p&gt;People often ask why I do so much of this by hand instead of running Burp Suite, OWASP ZAP, or one of the big enterprise scanners.&lt;/p&gt;

&lt;p&gt;I do use those tools. They are excellent at coverage. A DAST scanner will methodically poke every parameter on every endpoint. A dependency scanner will pick up known CVEs in your &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;package-lock.json&lt;/code&gt;. These are table stakes — you should run them no matter who is on your team.&lt;/p&gt;

&lt;p&gt;But automated tools find &lt;em&gt;known patterns&lt;/em&gt;. What I find by hand is different:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The CORS rule that accepts any subdomain — technically valid regex, but allows attacker-controlled origins&lt;/li&gt;
  &lt;li&gt;The scientific-notation invoice for one million dollars — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;1e6&lt;/code&gt; passes &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;IsNumber()&lt;/code&gt; validation because it &lt;em&gt;is&lt;/em&gt; a number, but the business logic never expected six figures&lt;/li&gt;
  &lt;li&gt;The internal fields that nobody should be allowed to set, but anyone can — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Object.assign(user, req.body)&lt;/code&gt; happily merges &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;isDeleted&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;role&lt;/code&gt; along with everything else&lt;/li&gt;
  &lt;li&gt;The webhook that accepts whatever you send it because validation was deferred to an async queue, which also does not validate — a design tradeoff that quietly became a security gap&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these have a CVE number. They are &lt;em&gt;business-logic vulnerabilities&lt;/em&gt; — the consequence of how this specific code, this specific ORM configuration, and this specific architectural decision interact. Scanners do not know a client&apos;s business rules; the person who reviews the diff does.&lt;/p&gt;

&lt;p&gt;That said, manual testing does not scale. I cannot personally &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;curl&lt;/code&gt; every endpoint before every release for every Clearview client. So we run both — automated tools for breadth, a human for depth — and every manual finding becomes a custom rule that catches the next instance automatically.&lt;/p&gt;

&lt;h2 id=&quot;the-role-we-wrote-around-it&quot;&gt;The Role We Wrote Around It&lt;/h2&gt;

&lt;p&gt;At some point my late-night reading turned into part of the job. It happened gradually.&lt;/p&gt;

&lt;p&gt;First I added security checks inside our Terraform modules — automated S3 bucket policy validation, IAM policy scanning, the kind of guard that would have caught those public buckets before they ever reached production.&lt;/p&gt;

&lt;p&gt;Then I started doing quiet pre-deployment reviews. Not formal audits. Just &lt;em&gt;&quot;let me look at this endpoint before it goes live.&quot;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Then, on a real client project, I found a vulnerability that would have let anyone create invoices without authentication. No login. No rate limiting. Just &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;curl -X POST&lt;/code&gt; and a JSON payload, every minute, forever.&lt;/p&gt;

&lt;p&gt;That was the inflection point. Not because it was the scariest finding I have ever shipped a report on, but because there was a dollar sign attached. The team — and the client — could see &lt;em&gt;exactly&lt;/em&gt; what an attacker would have done with it. Abstract security risk is easy to deprioritise. A vulnerability that prints fake invoices in your accounting system is not.&lt;/p&gt;

&lt;p&gt;After that, the role was real.&lt;/p&gt;

&lt;p&gt;Today, at Clearview Team, every new feature we ship to a client gets sent through a pen-tester pass before it lands in production. That pass is a known step in the sprint — scheduled and time-boxed. It is the same engineer (often me) sitting down with the diff and asking, on purpose, &lt;em&gt;how do I break this?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/images/posts/from-devops-to-devsecops-why-i-started-breaking-things/sprint-flow.svg&quot; alt=&quot;A four-stage sprint flow — Plan, Build, Pen-Tester Pass, Ship. The pen-tester pass is the accented middle stage, run by the same Clearview engineer who knows the codebase. Findings ship as pull requests in the same sprint, and every manual finding becomes a CI rule for the next one.&quot; /&gt;&lt;/p&gt;

&lt;p&gt;That is what an in-house pen tester gets you that an external annual audit does not. The feedback loop is short, and the fix usually lands in the same sprint that introduced the risk, not nine months later in a remediation backlog.&lt;/p&gt;

&lt;h2 id=&quot;what-i-catch-on-the-pen-tester-pass&quot;&gt;What I Catch on the Pen-Tester Pass&lt;/h2&gt;

&lt;p&gt;Here is the shortlist of categories that show up over and over again across stacks and clients:&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/images/posts/from-devops-to-devsecops-why-i-started-breaking-things/six-categories.svg&quot; alt=&quot;Six recurring vulnerability categories shown as a 2-by-3 grid: auth holes on new endpoints, mass assignment via ORM helpers, IDOR or BOLA on numeric IDs, input validation that is really type checking, rate limits that count the wrong IP, and infrastructure drift since the last engagement.&quot; /&gt;&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Auth holes on new endpoints.&lt;/strong&gt; Somebody adds a route, forgets the auth decorator, ships. I catch this before the route hits production by asserting every endpoint either carries &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@Authorized()&lt;/code&gt; or sits on an explicit public-endpoint allowlist.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Mass assignment via ORM helpers.&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Object.assign(entity, dto)&lt;/code&gt; looks innocent. It is not, when the entity has fields the user is never supposed to set. I run a curl against the endpoint with a payload that sets every internal field and see what survives.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;IDOR / BOLA on numeric IDs.&lt;/strong&gt; Anything in a URL of the form &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/invoices/:id&lt;/code&gt;. I swap in another tenant&apos;s id and read the response.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Input validation that is actually type checking.&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;IsNumber()&lt;/code&gt; lets &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;1e9&lt;/code&gt; through. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;IsString()&lt;/code&gt; lets a 50 MB payload through. I send the obvious edge cases and read the error messages.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Rate limits that count the wrong IP.&lt;/strong&gt; If &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;trust proxy&lt;/code&gt; is misconfigured, every request looks like it came from the load balancer. I send ten thousand requests with rotating &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;X-Forwarded-For&lt;/code&gt; headers and watch what the limiter thinks.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Infrastructure drift since the last engagement.&lt;/strong&gt; A bucket that was private six months ago is public now because someone toggled a setting in the console. I re-run &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;checkov&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tfsec&lt;/code&gt; against the live Terraform plan, not just the repo.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this is novel. The novelty is that it happens every sprint, by the same person, on the same engagement. Familiarity with the codebase is the hidden multiplier — an external auditor will never find the business-logic invoice bug, because they will not know what an invoice is supposed to look like for this client.&lt;/p&gt;

&lt;h2 id=&quot;what-it-gets-the-client&quot;&gt;What It Gets the Client&lt;/h2&gt;

&lt;p&gt;Clearview Team takes on web and mobile build engagements for startups and scaling companies. The teams we work with are usually small, usually shipping fast, and usually one configuration drift away from a story they would rather not be in.&lt;/p&gt;

&lt;p&gt;What the client gets:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;A feature is never the first thing to find a vulnerability in production.&lt;/strong&gt; A peer reading the diff with an attacker mindset is.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;They don&apos;t pay extra for the security review.&lt;/strong&gt; It is not a separate procurement, a separate vendor, or a separate quarter. It rides on the same sprint that ships the feature.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;When we find something, we hand them the fix.&lt;/strong&gt; I am an engineer. They do not get a thirty-page report — they get a pull request.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The codebase gets harder to break the longer we work on it.&lt;/strong&gt; Every finding becomes a CI rule that catches the next instance automatically. The boundary tightens by itself.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;No surprise certifications, no surprise breach.&lt;/strong&gt; When a SOC 2 auditor or a customer due-diligence questionnaire shows up, the answers are already true.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I will not put a percentage on what this prevents — I do not know what would have shipped without it, and Clearview does not invent numbers for sales copy. In the months since this role has been real, &lt;em&gt;no Clearview client has shipped a production-incident-class security bug from a feature that went through the pen-tester pass&lt;/em&gt;. The ones we have found in production were inherited from before our engagement, and we wrote up exactly what we found and exactly what we changed.&lt;/p&gt;

&lt;h2 id=&quot;what-this-costs-us&quot;&gt;What This Costs Us&lt;/h2&gt;

&lt;p&gt;Adding this role to every engagement is not free.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;A sprint takes a little longer.&lt;/strong&gt; Time-boxed, but real.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Some features get re-scoped.&lt;/strong&gt; When we hit something structural rather than local during the review, the fix is sometimes an architectural change. That conversation is uncomfortable, and we have it anyway.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The pen tester needs a peer.&lt;/strong&gt; I cannot review my own work alone — I have been wrong before and I will be again. The second opinion is part of the process, not an add-on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We treat all three as features of the model rather than bugs in it. A feature ships a little slower and holds up better in production.&lt;/p&gt;

&lt;h2 id=&quot;the-mindset-shift-for-anyone-considering-the-move&quot;&gt;The Mindset Shift, for Anyone Considering the Move&lt;/h2&gt;

&lt;p&gt;If you are a DevOps engineer reading this and wondering whether you should make the same shift — you do not need a certification. You do not need to become a full-time pen tester. You need to start asking &lt;em&gt;what if?&lt;/em&gt; about the systems you already own.&lt;/p&gt;

&lt;p&gt;Five things that helped me, in order:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Audit your own infrastructure first.&lt;/strong&gt; Run &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;checkov&lt;/code&gt; on your Terraform. Check your S3 bucket policies. Review your IAM roles. You will probably find something, and it will motivate everything that follows.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Learn one attack vector deeply.&lt;/strong&gt; Mass assignment if you use an ORM with auto-mapping. BOLA/IDOR if your API uses sequential IDs. Authentication bypass if you run your own auth flow. SSRF if your app makes outbound HTTP calls based on user input. Pick one. Learn it well enough to spot it in the wild. Then pick another.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Read the detailed breach post-mortems.&lt;/strong&gt; Not the sensational ones. Cloudflare&apos;s. GitLab&apos;s. The ones that explain the root cause, the timeline, and what changed. That is where real attacks live.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Break something — in staging, with permission.&lt;/strong&gt; Find a vulnerability. Exploit it responsibly. Document the steps. See how it feels. That feeling — the mix of excitement and dread — is how you know you are thinking like an attacker.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Turn every finding into a check.&lt;/strong&gt; Every manual finding should become a rule that runs automatically next time. Found a public S3 bucket? Add a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;checkov&lt;/code&gt; rule. Found a missing auth decorator? Add a CI test that verifies every route is on the auth list. The goal is to make yourself &lt;em&gt;unnecessary for the easy stuff&lt;/em&gt;, so you can spend your time on the hard stuff.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The paranoia helps, but the checklists help more.&lt;/p&gt;

&lt;aside class=&quot;callout&quot;&gt;
  &lt;p&gt;&lt;strong&gt;A quiet rule, but a firm one.&lt;/strong&gt; Only test against systems you own or have explicit written authorisation to test. &lt;em&gt;&quot;Staging&quot;&lt;/em&gt; does not mean &lt;em&gt;&quot;someone else&apos;s staging.&quot;&lt;/em&gt; The pen-tester pass at Clearview runs against client environments with named authorisation per engagement — never speculatively, never on infrastructure we do not have permission to touch.&lt;/p&gt;
&lt;/aside&gt;

&lt;aside class=&quot;post-cta&quot;&gt;
  &lt;h2 id=&quot;we-could-put-a-pen-tester-pass-on-your-sprint&quot;&gt;We Could Put a Pen-Tester Pass on Your Sprint&lt;/h2&gt;

  &lt;p&gt;If you are building a product and the security story is &lt;em&gt;&quot;we will get to it after launch,&quot;&lt;/em&gt; or &lt;em&gt;&quot;the framework handles it,&quot;&lt;/em&gt; or &lt;em&gt;&quot;we have a SOC 2 audit scheduled for next year&quot;&lt;/em&gt; — the gap between now and any of those is exactly where the bad stories happen.&lt;/p&gt;

  &lt;p&gt;This is the kind of engagement &lt;strong&gt;Clearview Team&lt;/strong&gt; takes on. A small distributed engineering team to build the feature; an in-house pen-tester pass on every sprint to make sure it holds when somebody pokes at it; a Terraform-first infrastructure layer that makes the secure path the easy path.&lt;/p&gt;

  &lt;p&gt;&lt;a href=&quot;mailto:info@clearview.team?subject=Pen-tester%20pass%20engagement%20enquiry&quot;&gt;Scope an engagement →&lt;/a&gt;&lt;/p&gt;
&lt;/aside&gt;
</content>
    <category term="devsecops" />
    <category term="security" />
    <category term="application-security" />
    <category term="pen-testing" />
    <category term="aws" />
    <category term="terraform" />
    <category term="mindset" />
    
  </entry>
  
  <entry>
    <title>An Event-Driven Architecture Case Study: Lessons from a Real-World Application</title>
    <link href="https://blog.clearview.team/2025/an-event-driven-architecture-case-study-lessons-from-a-real-world-application/" />
    <id>https://blog.clearview.team/2025/an-event-driven-architecture-case-study-lessons-from-a-real-world-application/</id>
    <published>2025-04-07T19:32:53+02:00</published>
    <updated>2025-04-07T19:32:53+02:00</updated>
    <author>
      <name>Nedim Hadzimahmutovic</name>
    </author>
    <summary>A decade of Clearview engineering for a confidential awards group, and the event-driven AWS architecture that keeps it observable.</summary>
    <content type="html">&lt;p&gt;Over the past decade, Clearview Team has built and maintained a pair of interconnected platforms for a confidential awards group. This is the event-driven AWS architecture that keeps both systems observable.&lt;/p&gt;

&lt;p&gt;&lt;a class=&quot;pdf-download&quot; href=&quot;https://drive.google.com/file/d/1F3mOTKY91DwKj7qQjOxIX9gAZIHKgA2q/view?usp=drive_link&quot; rel=&quot;external noopener&quot;&gt;
  &lt;span class=&quot;pdf-download__icon&quot; aria-hidden=&quot;true&quot;&gt;
    &lt;svg viewBox=&quot;0 0 24 24&quot; width=&quot;22&quot; height=&quot;22&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;1.8&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot;&gt;
      &lt;path d=&quot;M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z&quot;&gt;&lt;/path&gt;
      &lt;path d=&quot;M14 3v5h5&quot;&gt;&lt;/path&gt;
      &lt;path d=&quot;M12 12v6&quot;&gt;&lt;/path&gt;
      &lt;path d=&quot;M9 15l3 3 3-3&quot;&gt;&lt;/path&gt;
    &lt;/svg&gt;
  &lt;/span&gt;
  &lt;span class=&quot;pdf-download__body&quot;&gt;
    &lt;span class=&quot;pdf-download__label&quot;&gt;Prefer a downloadable version&lt;/span&gt;
    &lt;span class=&quot;pdf-download__title&quot;&gt;Read the case study as a PDF&lt;/span&gt;
  &lt;/span&gt;
  &lt;span class=&quot;pdf-download__cta&quot;&gt;
    Download &lt;span class=&quot;pdf-download__arrow&quot; aria-hidden=&quot;true&quot;&gt;↓&lt;/span&gt;
  &lt;/span&gt;
&lt;/a&gt;&lt;/p&gt;

&lt;h4 id=&quot;the-client&quot;&gt;The Client&lt;/h4&gt;

&lt;p&gt;A Confidential Non-profit and Awards Group
*Membership Operations, Automated and Simplified *&lt;/p&gt;

&lt;p&gt;At one time managed primarily through Excel spreadsheets and mail-in forms, the client’s membership system was functional but time-intensive for its administrators. With the benefit of an automated, online membership management system, client’s Board of Directors wisely contracted Clearview to combine member-related functions into one web-based platform.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;More than a decade later&lt;/strong&gt;, we’re still working in close partnership with the client to maintain and streamline member processes and relations through a secure, custom-built online portal. We’re continually finding new ways to empower administrators and members alike with rich tools and features, supporting this 501(c)(3) non-profit’s continued growth.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Purpose-built member dashboard&lt;/strong&gt; with quick access to useful member functions including online signup, membership upgrade, and renewal; info &amp;amp; address changes; member-only services and perks; automated password reset &amp;amp; forgotten login recovery; notification preferences.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Powerful administrator dashboard&lt;/strong&gt;, complete with tiered admin privileges, quick member lookup &amp;amp; sorting, bulk member management, numerous import/export options, and detailed reports.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Secure, multi-method payment processing&lt;/strong&gt; with in-dashboard payment logs, receipts, and refund processing.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Newsletter and notification management&lt;/strong&gt; including automated synchronization with MailChimp newsletter and SendGrid transactional email platforms.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Scheduled self-maintenance&lt;/strong&gt; and member relations tasks including proactive address validation and expiration notifications.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4 id=&quot;the-digital-workflow-platform&quot;&gt;The Digital Workflow Platform&lt;/h4&gt;

&lt;p&gt;&lt;em&gt;Annual Intake and Review Workflow&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The client also runs an annual intake-and-review cycle that once involved sorting through over a thousand hand-mailed physical packages. This veritable mountain of material was then shuffled through very extensive multi-stage review and selection processes by hand.&lt;/p&gt;

&lt;p&gt;Since joining the client’s team in 2010, Clearview has systematically brought each of these processes together in a single online platform.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Fully web-based intake process&lt;/strong&gt;, including secure online payment; upload of video clips, documents, and signoff forms; followup intake for supplementary materials.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Automated media verification, storage&lt;/strong&gt;, and transcoding, utilizing Vimeo API integration for high-quality, secure, and familiar playback for reviewers. All media is secured against unauthorized download.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Advanced partner dashboard&lt;/strong&gt;, enabling organizations with many moving parts to delegate intake tasks to multiple designated contributors and divide labor.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Review management&lt;/strong&gt;, including secure reviewer application and selection process; a dedicated review portal with high-quality material review, shortlisting, issue flagging, and discussion features; selection and notification workflows.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Selection management&lt;/strong&gt;, including application and eligibility processes and a secure selection interface.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Tight integration&lt;/strong&gt; with the organization’s membership database for permissions verification.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Extensive administration dashboard&lt;/strong&gt;, including tiered admin roles; organization and intake management; category and field management; payment, receipt, and transaction management; flexible sponsor-credits system; deadline management, late fee rules, and exceptions; numerous import/export tools.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Secure, triple-redundant data archival&lt;/strong&gt; following each year’s cycle.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;On-call support&lt;/strong&gt;, including email support for contributors, reviewers, and participants.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;the-project&quot;&gt;The Project&lt;/h3&gt;
&lt;p&gt;Clearview Team&apos;s engineers and clients are spread across time zones, so monitoring and incident response carry real coordination overhead. This engagement is where we chose &lt;strong&gt;Event-driven architecture.&lt;/strong&gt;&lt;/p&gt;

&lt;h4 id=&quot;challenges&quot;&gt;Challenges&lt;/h4&gt;

&lt;p&gt;The challenges we aim to solve with this approach:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Decoupling of services:&lt;/strong&gt; To improve the agility of the application we designed our system not to be tightly coupled.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Real-time responsiveness:&lt;/strong&gt; The application services operate on event-driven communication, which means the services can produce an event in real time. This enables our on-call engineers to react to important events that are produced by either user actions or system events.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Improved observability:&lt;/strong&gt; The event-driven approach improves system observability. By analyzing event flows, we constantly monitor and improve the system behavior, identify bottlenecks, and locate the root cause of issues more effectively.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4 id=&quot;deployment&quot;&gt;Deployment&lt;/h4&gt;

&lt;p&gt;The application is deployed on the AWS Cloud platform as AWS already provides all the communication buses that we need to produce, broker, and consume events.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Event Types&lt;/em&gt;
We have covered the most important event types in the app, such as:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Finance events&lt;/li&gt;
  &lt;li&gt;Membership events&lt;/li&gt;
  &lt;li&gt;Scheduled job events&lt;/li&gt;
  &lt;li&gt;Health check events.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4 id=&quot;goals&quot;&gt;Goals&lt;/h4&gt;

&lt;p&gt;This project aims to solve the client’s requirements to create and continuously improve the membership and awards system while implementing a modern system architecture. More details about what kind of problems the application is designed to solve can be seen in the diagram below.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/images/posts/an-event-driven-architecture-case-study-lessons-from-a-real-world-application/cover.svg&quot; alt=&quot;Editorial cover — three event sources (submit, vote, payment) fan into an AWS EventBridge bus, which fans out to a Lambda, an SQS queue, and a Kinesis stream.&quot; /&gt;&lt;/p&gt;

&lt;h3 id=&quot;system-architecture&quot;&gt;System Architecture&lt;/h3&gt;
&lt;p&gt;This application is built around separate services, each running inside its own container. Services communicate through events — state changes that trigger actions elsewhere in the system.&lt;/p&gt;

&lt;h4 id=&quot;event-driven-architecture&quot;&gt;Event-driven Architecture&lt;/h4&gt;
&lt;p&gt;An event is a state change — a user placing an order, a payment failing, a scheduled job firing. Services produce events and other services consume them, without needing to know about each other directly.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Event-driven architecture is often called &lt;strong&gt;EDA&lt;/strong&gt; for short.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The rest of this post covers how we wired the event bus for job scheduling and monitoring — the piece that lets our team get &lt;strong&gt;notified in real-time&lt;/strong&gt; and react before downtime reaches users.&lt;/p&gt;

&lt;h4 id=&quot;event-driven-architecture-showcase&quot;&gt;Event-driven Architecture Showcase&lt;/h4&gt;

&lt;p&gt;Below is a diagram showing what a typical &lt;strong&gt;EDA&lt;/strong&gt; system looks like and how it functions.&lt;/p&gt;

&lt;figure class=&quot;post-figure&quot;&gt;
  &lt;img src=&quot;/assets/images/posts/an-event-driven-architecture-case-study-lessons-from-a-real-world-application/1_JlwE8qJp69RxLHu9q51GgQ.png&quot; width=&quot;1024&quot; height=&quot;812&quot; alt=&quot;AWS based Event-driven architecture&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; /&gt;
  &lt;figcaption&gt;AWS based Event-driven architecture&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;h3 id=&quot;the-application&quot;&gt;The Application&lt;/h3&gt;
&lt;p&gt;The application is built on &lt;strong&gt;NodeJS&lt;/strong&gt; with an API service serving multiple front-end domains. This approach has served us well. We have been running the app as a container on AWS.&lt;/p&gt;

&lt;figure class=&quot;post-figure&quot;&gt;
  &lt;img src=&quot;/assets/images/posts/an-event-driven-architecture-case-study-lessons-from-a-real-world-application/1_2noPlSZ42q4dacE1fg34HQ.png&quot; width=&quot;1024&quot; height=&quot;298&quot; alt=&quot;The diagram shows API and the DB secured and isolated in the VPC. Frontend is served to the client via CloudFront which pulls the website assets from the S3 bucket.&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; /&gt;
  &lt;figcaption&gt;The diagram shows API and the DB secured and isolated in the VPC. Frontend is served to the client via CloudFront which pulls the website assets from the S3 bucket.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;blockquote&gt;
  &lt;p&gt;Please keep in mind that the client communicates with the API after it has been loaded on the client-side rendered website.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3 id=&quot;event-producers&quot;&gt;Event Producers&lt;/h3&gt;
&lt;p&gt;Events are generated from various services and can be produced either by a client action or by a scheduled job.&lt;/p&gt;

&lt;h4 id=&quot;api-event-producer&quot;&gt;API Event Producer&lt;/h4&gt;

&lt;p&gt;The best example of a client-produced event is any type of event related to credit card payments. The app uses the “@aws-sdk/client-eventbridge” library to communicate with the AWS Event Bridge service.&lt;/p&gt;

&lt;h4 id=&quot;api-events-list&quot;&gt;API Events List&lt;/h4&gt;

&lt;p&gt;The core services of the app produce events. Below is a list of services with a description of what each does.&lt;/p&gt;

&lt;figure class=&quot;post-figure&quot;&gt;
  &lt;img src=&quot;/assets/images/posts/an-event-driven-architecture-case-study-lessons-from-a-real-world-application/1_r3U4h2KgQ9b15_tqA7BKAA.png&quot; width=&quot;1024&quot; height=&quot;500&quot; alt=&quot;The diagram shows API Services and Controllers that send events.&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; /&gt;
  &lt;figcaption&gt;The diagram shows API Services and Controllers that send events.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;h4 id=&quot;api-produced-sample-events&quot;&gt;API Produced Sample Events&lt;/h4&gt;

&lt;p&gt;&lt;em&gt;Finance Related Events&lt;/em&gt;
Below you can find payment processing failed sample events&lt;/p&gt;

&lt;figure class=&quot;post-figure&quot;&gt;
  &lt;img src=&quot;/assets/images/posts/an-event-driven-architecture-case-study-lessons-from-a-real-world-application/1_xAgJylpdUNi2_yDVzKGskg.png&quot; width=&quot;1024&quot; height=&quot;1166&quot; alt=&quot;Health Check Events&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; /&gt;
  &lt;figcaption&gt;Health Check Events&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;figure class=&quot;post-figure&quot;&gt;
  &lt;img src=&quot;/assets/images/posts/an-event-driven-architecture-case-study-lessons-from-a-real-world-application/1_O0IsEOLwk9HGZcbu5a8cWA.png&quot; width=&quot;818&quot; height=&quot;1222&quot; alt=&quot;Mailing List Events&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; /&gt;
  &lt;figcaption&gt;Mailing List Events&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;&lt;img src=&quot;/assets/images/posts/an-event-driven-architecture-case-study-lessons-from-a-real-world-application/1_s8Nk7t3gSSio5cGshNekXQ.png&quot; alt=&quot;Event-driven architecture diagram&quot; /&gt;&lt;/p&gt;

&lt;h4 id=&quot;scheduler-event-producer&quot;&gt;Scheduler Event Producer&lt;/h4&gt;

&lt;p&gt;As it was not necessary to &lt;strong&gt;re-implement the wheel&lt;/strong&gt; and create our scheduling service, we relied on the well-tested and proven &lt;em&gt;AWS EventBridge Scheduler&lt;/em&gt; for the scheduling system. For this purpose a custom and dedicated Event Bus was created and named Client Production Scheduler Bus.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;In this case, the Event Producer is the Scheduler itself.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;figure class=&quot;post-figure&quot;&gt;
  &lt;img src=&quot;/assets/images/posts/an-event-driven-architecture-case-study-lessons-from-a-real-world-application/1_ToHAJzE0Jw55CmjYVPy86w.png&quot; width=&quot;1024&quot; height=&quot;934&quot; alt=&quot;The diagram shows events that are sent from the first event producer which is the Event Bridge Scheduler received and processed by The Production Event Bus, parsed by AWS Lambda, and at the end sent to a Slack webhook.&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; /&gt;
  &lt;figcaption&gt;The diagram shows events that are sent from the first event producer which is the Event Bridge Scheduler received and processed by The Production Event Bus, parsed by AWS Lambda, and at the end sent to a Slack webhook.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;As per the diagram every cron job has the following flow:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;a scheduler&lt;/strong&gt; of recurring type that produces an event and sends it to the custom scheduler bus,&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;an endpoint&lt;/strong&gt; that is defined as part of the &lt;strong&gt;api_destination&lt;/strong&gt; event rule.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4 id=&quot;evaluated-jobs&quot;&gt;Evaluated Jobs&lt;/h4&gt;

&lt;p&gt;The complete list can be found below.&lt;/p&gt;

&lt;figure class=&quot;post-figure&quot;&gt;
  &lt;img src=&quot;/assets/images/posts/an-event-driven-architecture-case-study-lessons-from-a-real-world-application/1_7bEmP87hPbCpHoFooiA_yQ.png&quot; width=&quot;1024&quot; height=&quot;447&quot; alt=&quot;The diagram shows the list of Scheduled jobs, their endpoints, and json payload event they generate which gets sent to the Event Bus.&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; /&gt;
  &lt;figcaption&gt;The diagram shows the list of Scheduled jobs, their endpoints, and json payload event they generate which gets sent to the Event Bus.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;h4 id=&quot;company-statistic-scheduled-job&quot;&gt;Company Statistic Scheduled Job&lt;/h4&gt;

&lt;figure class=&quot;post-figure&quot;&gt;
  &lt;img src=&quot;/assets/images/posts/an-event-driven-architecture-case-study-lessons-from-a-real-world-application/1_HWvN5-hrVkBoB_dMloEovQ.png&quot; width=&quot;1024&quot; height=&quot;716&quot; alt=&quot;The diagram shows events that are produced the Event Bridge Scheduler, received and processed by The Production Event Bus, and forwarded to the target which is unique endpoint via API Destination.&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; /&gt;
  &lt;figcaption&gt;The diagram shows events that are produced the Event Bridge Scheduler, received and processed by The Production Event Bus, and forwarded to the target which is unique endpoint via API Destination.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Event Scheduler Job Notes:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Recurring schedule type&lt;/li&gt;
  &lt;li&gt;Cron expression style&lt;/li&gt;
  &lt;li&gt;Runs every 8 hours&lt;/li&gt;
  &lt;li&gt;Target: Scheduler Bus&lt;/li&gt;
  &lt;li&gt;API PutEvents&lt;/li&gt;
&lt;/ul&gt;

&lt;h4 id=&quot;event-rule&quot;&gt;Event Rule&lt;/h4&gt;

&lt;p&gt;The bus receives the event and triggers the api cron company statistic rule.&lt;/p&gt;

&lt;figure class=&quot;post-figure&quot;&gt;
  &lt;img src=&quot;/assets/images/posts/an-event-driven-architecture-case-study-lessons-from-a-real-world-application/1_j163Hp8a2tgsdighb-ZNcg.png&quot; width=&quot;1024&quot; height=&quot;395&quot; alt=&quot;API Destination&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; /&gt;
  &lt;figcaption&gt;API Destination&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;&lt;img src=&quot;/assets/images/posts/an-event-driven-architecture-case-study-lessons-from-a-real-world-application/1_v4C4WRHUZntsaZPQjN2G9w.png&quot; alt=&quot;Event-driven architecture diagram&quot; /&gt;&lt;/p&gt;

&lt;h3 id=&quot;the-infrastructure&quot;&gt;The Infrastructure&lt;/h3&gt;
&lt;p&gt;In this section, we will cover how the AWS infrastructure was set up, the services that send events to the default Event Bus, and how we process and consume those events.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;The AWS Account&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We used AWS Organizations to separate Production and Development environments. This is important as infrastructure-related events are sent to the default event bus. We wanted production and development events not to trigger the same rules as this could lead to unexpected behaviors.&lt;/p&gt;

&lt;figure class=&quot;post-figure&quot;&gt;
  &lt;img src=&quot;/assets/images/posts/an-event-driven-architecture-case-study-lessons-from-a-real-world-application/1_Sl6mV4_BKq-9Fh4AcM3jtw.png&quot; width=&quot;1024&quot; height=&quot;696&quot; alt=&quot;The diagram shows how we separated production and development accounts while managing both with a single management account.&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; /&gt;
  &lt;figcaption&gt;The diagram shows how we separated production and development accounts while managing both with a single management account.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;It makes it easier to have two environments not mixing up as we know for a fact that development events will not end up on the production Event Bus causing potential problems.&lt;/p&gt;

&lt;h4 id=&quot;aws-rds&quot;&gt;AWS RDS&lt;/h4&gt;

&lt;p&gt;The database service’s health and availability are very important for the app’s reliability. Therefore, we have a setup of monitoring database events with Slack notifications.&lt;/p&gt;

&lt;p&gt;Every time there is an event such as the database service backing up an event is created and sent to the Event Bus. Such an event will end up as a Slack notification and be read by our team.&lt;/p&gt;

&lt;figure class=&quot;post-figure&quot;&gt;
  &lt;img src=&quot;/assets/images/posts/an-event-driven-architecture-case-study-lessons-from-a-real-world-application/1_OiFXhQsnZMFppyx3zlBL6Q.png&quot; width=&quot;1024&quot; height=&quot;759&quot; alt=&quot;The diagram shows events that are sent from the MariaDB service, received and processed by AWS EventBridge, parsed by AWS Lambda, and at the end sent to a Slack webhook.&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; /&gt;
  &lt;figcaption&gt;The diagram shows events that are sent from the MariaDB service, received and processed by AWS EventBridge, parsed by AWS Lambda, and at the end sent to a Slack webhook.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;blockquote&gt;
  &lt;p&gt;We decided that the following event categories will be forwarded to Slack: Notification, availability, backup, failure, low storage, maintenance and recovery.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h4 id=&quot;the-eventbridge-rule&quot;&gt;The EventBridge Rule&lt;/h4&gt;

&lt;p&gt;&lt;img src=&quot;/assets/images/posts/an-event-driven-architecture-case-study-lessons-from-a-real-world-application/1_uLTdeDSNNGkcKN1UOCqy0g.png&quot; alt=&quot;Event-driven architecture diagram&quot; /&gt;&lt;/p&gt;

&lt;h4 id=&quot;aws-health&quot;&gt;AWS Health&lt;/h4&gt;

&lt;figure class=&quot;post-figure&quot;&gt;
  &lt;img src=&quot;/assets/images/posts/an-event-driven-architecture-case-study-lessons-from-a-real-world-application/1_609A4DS0lb9LF2DCiAmZdg.png&quot; width=&quot;1024&quot; height=&quot;685&quot; alt=&quot;The diagram shows events that are sent from the AWS Health service, received and processed by AWS EventBridge, parsed by AWS Lambda, and at the end sent to a Slack webhook.&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; /&gt;
  &lt;figcaption&gt;The diagram shows events that are sent from the AWS Health service, received and processed by AWS EventBridge, parsed by AWS Lambda, and at the end sent to a Slack webhook.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Events type can be one of the following categories:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;accountNotification&lt;/li&gt;
  &lt;li&gt;issue&lt;/li&gt;
  &lt;li&gt;scheduledChange&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;AWS Health events are sent to the &lt;strong&gt;default&lt;/strong&gt; Event Bus. We want to react when we receive an event from the AWS Health service, therefore we create rules. For example, you can use AWS Health to receive email notifications if you have AWS resources in your AWS account scheduled for updates, such as EC2 instances. Below you can find examples of such rules.&lt;/p&gt;

&lt;h4 id=&quot;ec2-service&quot;&gt;EC2 service&lt;/h4&gt;

&lt;p&gt;&lt;img src=&quot;/assets/images/posts/an-event-driven-architecture-case-study-lessons-from-a-real-world-application/1_mkqrarvjHW-AOxG7ZA7XZQ.png&quot; alt=&quot;Event-driven architecture diagram&quot; /&gt;&lt;/p&gt;

&lt;h4 id=&quot;sample-rules&quot;&gt;Sample rules&lt;/h4&gt;

&lt;p&gt;&lt;em&gt;Trigger on Every Event&lt;/em&gt;&lt;/p&gt;

&lt;figure class=&quot;post-figure&quot;&gt;
  &lt;img src=&quot;/assets/images/posts/an-event-driven-architecture-case-study-lessons-from-a-real-world-application/1_hymOfaBeob4yFAGwYVkpPg.png&quot; width=&quot;1024&quot; height=&quot;306&quot; alt=&quot;Trigger on Specific Service-related Event&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; /&gt;
  &lt;figcaption&gt;Trigger on Specific Service-related Event&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Rule for a specific service and event type category.&lt;/p&gt;

&lt;p&gt;In this example, we will create a rule so that EventBridge reacts to the following.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/images/posts/an-event-driven-architecture-case-study-lessons-from-a-real-world-application/1_QtFrPLM5IxieQQHNR99hUQ.png&quot; alt=&quot;Event-driven architecture diagram&quot; /&gt;&lt;/p&gt;

&lt;h4 id=&quot;multiple-services-and-event-type-categories&quot;&gt;Multiple Services and Event Type Categories&lt;/h4&gt;

&lt;p&gt;The examples in the previous procedure show you how to create a rule for a single service and event type category. You can also create a rule for multiple services and event-type categories. This means that you don’t have to create a separate rule for each service and category that you want to monitor. To do so, you must edit the event pattern as per following example&lt;/p&gt;

&lt;h4 id=&quot;example-rule&quot;&gt;Example Rule&lt;/h4&gt;

&lt;p&gt;&lt;img src=&quot;/assets/images/posts/an-event-driven-architecture-case-study-lessons-from-a-real-world-application/1_3WDUut-SEV5rkMaP2QIw-A.png&quot; alt=&quot;Event-driven architecture diagram&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/images/posts/an-event-driven-architecture-case-study-lessons-from-a-real-world-application/1_3ZKR4sD7DNN-ZyIejrP2ng.png&quot; alt=&quot;Event-driven architecture diagram&quot; /&gt;&lt;/p&gt;

&lt;aside class=&quot;post-cta&quot;&gt;
  &lt;h2 id=&quot;we-could-architect-your-event-flow&quot;&gt;We Could Architect Your Event Flow&lt;/h2&gt;

  &lt;p&gt;If you are scaling a product whose data flow is starting to outgrow request/response, or if the integrations between your services are getting harder to reason about, this is exactly the kind of engagement &lt;strong&gt;Clearview Team&lt;/strong&gt; takes on. We design event-driven architectures on AWS — EventBridge, SQS, Lambda, Step Functions — with the observability and replay story baked in from day one.&lt;/p&gt;

  &lt;p&gt;&lt;a href=&quot;/work-with-us/&quot;&gt;Scope an engagement →&lt;/a&gt;&lt;/p&gt;
&lt;/aside&gt;
</content>
    <category term="application-development" />
    <category term="event-driven-architecture" />
    <category term="case-study" />
    <category term="system-architecture" />
    <category term="aws-devops" />
    
  </entry>
  
  <entry>
    <title>Migrating EC2 to AWS Graviton (ARM): A Cost-Reduction Case Study</title>
    <link href="https://blog.clearview.team/2024/embracing-the-future-of-cloud-computing/" />
    <id>https://blog.clearview.team/2024/embracing-the-future-of-cloud-computing/</id>
    <published>2024-02-11T05:59:43+01:00</published>
    <updated>2024-02-11T05:59:43+01:00</updated>
    <author>
      <name>Nedim Hadzimahmutovic</name>
    </author>
    <summary>How we cut Clearview client EC2 bills by up to 20% by migrating x86 workloads to AWS Graviton (ARM64) — the instance-family swap (M6g, C7gd, R7g), the AMI-selection step in Terraform, and the compatibility checks we ran first.</summary>
    <content type="html">&lt;p&gt;&lt;img src=&quot;/assets/images/posts/embracing-the-future-of-cloud-computing/cover.svg&quot; alt=&quot;Editorial cover — an ARM Graviton processor die with circuit traces, brand-coloured glow, set on a dark Ink backdrop.&quot; /&gt;&lt;/p&gt;

&lt;p&gt;The world of cloud computing is changing fast, that is a fact. Some changes have a more significant impact, as was the case with the rise of AWS Graviton processors.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;The whole tech industry is experiencing the growing adoption of &lt;strong&gt;ARM&lt;/strong&gt; architecture, so why should it not be the case with the Cloud as well?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This article focuses on the impact of AWS Graviton on the Cloud offering, the shift towards the &lt;strong&gt;ARM&lt;/strong&gt; architecture, and how &lt;strong&gt;Clearview&lt;/strong&gt; is helping clients jump on the Graviton train and &lt;strong&gt;save on costs up to 20%&lt;/strong&gt;.&lt;/p&gt;

&lt;h3 id=&quot;key-points-on-aws-graviton&quot;&gt;Key points on AWS Graviton&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;AWS Graviton processors are becoming popular because of their &lt;strong&gt;superior performance&lt;/strong&gt; and &lt;strong&gt;energy efficiency&lt;/strong&gt; in cloud environments which reduces your carbon footprint,&lt;/li&gt;
  &lt;li&gt;Adapting the ARM architecture &lt;strong&gt;requires data migration&lt;/strong&gt; or &lt;strong&gt;possible code improvements&lt;/strong&gt; before benefiting from the superior improvements in both cost and performance.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;the-rise-of-aws-graviton&quot;&gt;The Rise of AWS Graviton&lt;/h3&gt;

&lt;h4 id=&quot;understanding-aws-graviton-processors&quot;&gt;Understanding AWS Graviton Processors&lt;/h4&gt;

&lt;p&gt;&lt;img src=&quot;/assets/images/posts/embracing-the-future-of-cloud-computing/processors.svg&quot; alt=&quot;An ARM Graviton die at the centre with three benefit callouts radiating outward: performance, cost-effectiveness, energy efficiency.&quot; /&gt;&lt;/p&gt;

&lt;p&gt;AWS Graviton processors are powered by &lt;strong&gt;ARM&lt;/strong&gt; architecture, enabling performance and cost optimizations, which represent a significant advancement in cloud computing, compared to traditional x86 processors.&lt;/p&gt;

&lt;p&gt;Graviton processors are optimized for Cloud workloads with the following benefits:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Large L1 and L2 &lt;strong&gt;caches for every virtual central processing unit&lt;/strong&gt; (vCPU), meaning that most of your workload never burdens RAM,&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Every virtual CPU is a physical core&lt;/strong&gt;, meaning more isolation between virtual CPUs,&lt;/li&gt;
  &lt;li&gt;Cores are &lt;strong&gt;connected in a fast mesh&lt;/strong&gt; with ~2TB/s of bisection bandwidth, which allows applications to move very quickly from core to core,&lt;/li&gt;
  &lt;li&gt;Graviton’s RAM architecture means &lt;strong&gt;you don’t need to worry about application memory allocation&lt;/strong&gt;, or &lt;strong&gt;which cores are running the application&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;generations-of-aws-graviton&quot;&gt;Generations of AWS Graviton&lt;/h3&gt;

&lt;p&gt;Graviton processors come in &lt;strong&gt;various generations&lt;/strong&gt; with each generation offering various advances in processing power and efficiency.&lt;/p&gt;

&lt;h4 id=&quot;graviton&quot;&gt;Graviton&lt;/h4&gt;

&lt;p&gt;The &lt;strong&gt;first generation&lt;/strong&gt; ARM architecture-based, known as A1 type, Graviton-powered EC2 instances were launched in 2018. Not every region is still providing this first-generation instance type. Use the following awscli command to check if a region supports the A1 instance type.&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;aws ec2 describe-instance-type-offerings &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--location-type&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;availability-zone&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--filters&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;Name&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;location,Values&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;us-east-2a &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--region&lt;/span&gt; us-east-2 &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--query&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;InstanceTypeOfferings[*].[InstanceType]&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--output&lt;/span&gt; text &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--profile&lt;/span&gt; default | &lt;span class=&quot;nb&quot;&gt;sort&lt;/span&gt; | &lt;span class=&quot;nb&quot;&gt;grep &lt;/span&gt;a1
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This processor features:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;64-bit ARM Neoverse cores,&lt;/li&gt;
  &lt;li&gt;The instances are up to 40% less expensive than the same number of vCPUs and DRAM available in other instance types.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4 id=&quot;graviton-2&quot;&gt;Graviton 2&lt;/h4&gt;

&lt;p&gt;Launched in 2019 it represents the &lt;strong&gt;second generation&lt;/strong&gt; of AWS Graviton processes. Graviton2-based instance types offer up to 40% better price performance than fifth-generation instances.&lt;/p&gt;

&lt;h4 id=&quot;graviton-2-instance-types&quot;&gt;Graviton 2 Instance types&lt;/h4&gt;

&lt;p&gt;We now have 12 instance families (M6g, M6gd, C6g, C6gd, C6gn, R6g, R6gd, T4g, X2gd, Im4gn, Is4gen, and G5g) that are powered by AWS Graviton2 processors that provide significant price performance benefits for a wide range of workloads.&lt;/p&gt;

&lt;h4 id=&quot;graviton-3&quot;&gt;Graviton 3&lt;/h4&gt;

&lt;p&gt;Launched in 2022, it is the &lt;strong&gt;third and latest generation&lt;/strong&gt; that is generally available. When compared to AWS Graviton2 processors it provides:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;up to &lt;strong&gt;25% better computing&lt;/strong&gt; performance,&lt;/li&gt;
  &lt;li&gt;up to 2x better floating-point performance,&lt;/li&gt;
  &lt;li&gt;up to 2x faster crypto performance, and&lt;/li&gt;
  &lt;li&gt;up to 3x better ML performance,&lt;/li&gt;
  &lt;li&gt;support for bfloat16,&lt;/li&gt;
  &lt;li&gt;and features the latest DDR5 memory, which provides 50% more memory bandwidth compared to DDR4.&lt;/li&gt;
  &lt;li&gt;up to &lt;strong&gt;60% less energy usage&lt;/strong&gt; for the same performance than comparable EC2 instances.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4 id=&quot;graviton-3-instance-types&quot;&gt;Graviton 3 instance types&lt;/h4&gt;

&lt;p&gt;All 7th generation instance types containing “g” means it is based on AWS Graviton 3, the silicon designed by AWS. Examples:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;C7g, C7gd, C7gn:&lt;/strong&gt; the “C” instance family is designed for compute-intensive workloads,&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;M7g&lt;/strong&gt;, &lt;strong&gt;M7gd&lt;/strong&gt;: “G” instance family is designed for general-purpose workloads with balanced computing, memory, and networking,&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;R7g, R7gd&lt;/strong&gt;: The “R” instance family is designed for memory-intensive workloads.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4 id=&quot;graviton-4&quot;&gt;Graviton 4&lt;/h4&gt;

&lt;p&gt;Launched in late November 2023 Graviton 4 represents the &lt;strong&gt;fourth generation&lt;/strong&gt; and it is &lt;strong&gt;still in preview only&lt;/strong&gt;, with general availability planned in Q1 of 2024. When compared to Graviton3 processors it provides:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;up to &lt;strong&gt;30% better&lt;/strong&gt; computing performance,&lt;/li&gt;
  &lt;li&gt;50% more cores,&lt;/li&gt;
  &lt;li&gt;75% more memory bandwidth.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Graviton4 will be available in memory-optimized Amazon EC2 R8g instances, which are currently in preview only. R8g instances offer larger instance sizes with up to 3x more vCPUs and 3x more memory than current generation R7g instances. To learn more about Graviton4-based R8g instances, visit this &lt;a href=&quot;https://aws.amazon.com/ec2/instance-types/r8g/&quot;&gt;link&lt;/a&gt;.&lt;/p&gt;

&lt;h3 id=&quot;switch-your-aws-services-to-graviton&quot;&gt;Switch your AWS Services to Graviton&lt;/h3&gt;

&lt;p&gt;&lt;img src=&quot;/assets/images/posts/embracing-the-future-of-cloud-computing/migration.svg&quot; alt=&quot;Three migration rows from x86 to Graviton — EC2 m5 → m7g, RDS db.m5 → db.m7g, ElastiCache cache.m5 → cache.m7g.&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Here we will describe which services we have switched over to AWS Graviton.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Some services can be switched with a few clicks, while others require a full data migration following best practices.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h4 id=&quot;aws-ec2&quot;&gt;AWS EC2&lt;/h4&gt;

&lt;p&gt;Following is a tutorial on how we switched &lt;strong&gt;EC2&lt;/strong&gt; instances. Please note that t4g is run on &lt;strong&gt;Graviton 2&lt;/strong&gt;. Migrating our NodeJScode to be compatible with &lt;strong&gt;ARM&lt;/strong&gt; architecture meant upgrading a few packages only.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Note: do not forget to set your awscli profile as in all of these examples the default one is used.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h4 id=&quot;find-arm-lts-ubuntu-images&quot;&gt;Find ARM LTS Ubuntu Images&lt;/h4&gt;

&lt;p&gt;For the latest LTS which is 22.04 LTS (Jammy Jellyfish), currently.&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;aws ec2 describe-images &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--filters&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Name=name,Values=ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-arm64-server-*&quot;&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Name=architecture,Values=arm64&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--query&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;sort_by(Images, &amp;amp;CreationDate)[].[Name, ImageId, Architecture]&quot;&lt;/span&gt;  &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--output&lt;/span&gt; text &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--region&lt;/span&gt; us-east-1 &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--profile&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;default
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This command will show you the IDs of the images that support the ARM architecture. Choose one and set it at the next step.&lt;/p&gt;

&lt;h4 id=&quot;set-ami-id&quot;&gt;Set AMI ID&lt;/h4&gt;

&lt;p&gt;As the AMI ID has been found in the previous command, we need to save it to a variable.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;AMI_ID=ami-02ddaf75821f25213
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h4 id=&quot;list-ec2-key-pairs&quot;&gt;List EC2 Key Pairs&lt;/h4&gt;

&lt;p&gt;The following commands will show keys that were added previously. If you get an empty result you need to add your public SSH key.&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;aws ec2 describe-key-pairs &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--region&lt;/span&gt; us-east-1 &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--profile&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;default
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h4 id=&quot;find-the-availability-zone&quot;&gt;Find the Availability Zone&lt;/h4&gt;

&lt;p&gt;The zone is directly connected to the subnet.&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;aws ec2 describe-instance-type-offerings &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;--location-type&lt;/span&gt; availability-zone &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--filters&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;Name&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;instance-type,Values&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;t4g.small &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--region&lt;/span&gt; us-east-1 &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--profile&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;default
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;blockquote&gt;
  &lt;p&gt;Note: This step is very important as you need to find the what availability zone supports your instance size.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h4 id=&quot;find-the-subnet-and-security-group&quot;&gt;Find the Subnet and Security Group&lt;/h4&gt;

&lt;p&gt;We need to find the correct Subnet and SG.&lt;/p&gt;

&lt;h4 id=&quot;list-security-groups&quot;&gt;List Security Groups&lt;/h4&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;aws ec2 describe-security-groups &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--query&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;SecurityGroups[*].{Name:GroupName,ID:GroupId}&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--region&lt;/span&gt; us-east-1 &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--profile&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;default
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h4 id=&quot;list-subnets&quot;&gt;List subnets&lt;/h4&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;aws ec2 describe-subnets &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--region&lt;/span&gt; us-east-1 &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--profile&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;default
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;blockquote&gt;
  &lt;p&gt;Note: Find the correct subnet that is used by your old EC2 instance.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h4 id=&quot;create-the-ec2-instance&quot;&gt;Create the EC2 Instance&lt;/h4&gt;

&lt;p&gt;Please note that the following command is an example only and that you need to change the values for:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;the key name,&lt;/li&gt;
  &lt;li&gt;the subnet ID,&lt;/li&gt;
  &lt;li&gt;the security group ID,&lt;/li&gt;
  &lt;li&gt;set your tags,&lt;/li&gt;
  &lt;li&gt;use your profile name.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt; &lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;aws ec2 run-instances &lt;span class=&quot;nt&quot;&gt;--image-id&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;$AMI_ID&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--instance-type&lt;/span&gt; t4g.small  &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--key-name&lt;/span&gt; nedim &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--subnet-id&lt;/span&gt; subnet-000000000000000 &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--security-group-ids&lt;/span&gt; sg-00000000000000000 &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--associate-public-ip-address&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--tag-specifications&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;ResourceType=instance,Tags=[{Key=Name,Value=ubuntu-test-graviton}]&apos;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--region&lt;/span&gt; us-east-1 &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--profile&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;default
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h4 id=&quot;prepare-the-image&quot;&gt;Prepare the image&lt;/h4&gt;

&lt;p&gt;Connect to the VM, and run the system upgrade.&lt;/p&gt;

&lt;h4 id=&quot;new-server-configuration-with-ansible&quot;&gt;New Server Configuration with Ansible&lt;/h4&gt;

&lt;p&gt;Since we used Ansible to set up the old server the process of setting up an identically configured server was a piece of cake. All we had to do was change the hostname in the host inventory file add the new server and rerun the playbooks.&lt;/p&gt;

&lt;h4 id=&quot;volume-size&quot;&gt;Volume size&lt;/h4&gt;

&lt;p&gt;Make sure the new disk volume is identical to the size of the old server’s disk volume as we will copy over data from the old server to the new one.&lt;/p&gt;

&lt;h4 id=&quot;data-migration&quot;&gt;Data Migration&lt;/h4&gt;

&lt;p&gt;Using RSYNC, copy over the data from the old server to the new Graviton-based one, using the SSH protocol.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;rsync -avz -e ssh /var/www/ root@enew-server.compute-1.amazonaws.com:/var/www/
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;blockquote&gt;
  &lt;p&gt;Note: This data migration does not guarantee that the code will work 100%, you will need to deploy and build the code again to be sure it works as expected. In our case, we needed to upgrade several NodeJS packages.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3 id=&quot;aws-rds&quot;&gt;AWS RDS&lt;/h3&gt;

&lt;p&gt;With RDS instances no migration is necessary and you can take the easy way of just modifying the instance class of your instances if your database engine version supports it.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Determine if the current database version meets the minimum required version for moving to Graviton2.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Version supporting Graviton:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;MySQL&lt;/strong&gt;: 8.0.17 and higher,&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;PostgreSQL&lt;/strong&gt;: 12.3, 13 and higher,&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;MariaDB&lt;/strong&gt;: 10.4.13, 10.5 and higher.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your RDS instance isn’t at a version supported by Graviton2, you need to upgrade to a supported version.&lt;/p&gt;

&lt;h3 id=&quot;aws-elasticache-for-redis&quot;&gt;AWS ElastiCache for Redis&lt;/h3&gt;

&lt;p&gt;Migrating Amazon Elasticache for Redis and Memcached to Graviton2-based instances offers a &lt;strong&gt;smooth transition&lt;/strong&gt;, only a change of instance type is required, with a performance improvement of up to 45%, all at a 20% lower cost compared to similar x86-based instances.&lt;/p&gt;

&lt;h3 id=&quot;the-beginning-of-a-new-age-in-cloud-computing&quot;&gt;The Beginning of a New Age in Cloud Computing&lt;/h3&gt;

&lt;p&gt;In conclusion, the rise of &lt;strong&gt;AWS Graviton&lt;/strong&gt; and &lt;strong&gt;ARM&lt;/strong&gt; architecture represents a change in cloud computing that impacts the whole IT industry. These technologies bring together &lt;strong&gt;improved performance&lt;/strong&gt;, &lt;strong&gt;cost-effectiveness&lt;/strong&gt;, and &lt;strong&gt;energy efficiency&lt;/strong&gt;. As more organizations adopt these technologies, we can expect a future where the flexibility of &lt;strong&gt;ARM&lt;/strong&gt; &lt;strong&gt;architecture&lt;/strong&gt; and the efficiency of &lt;strong&gt;Graviton&lt;/strong&gt; processors set the &lt;strong&gt;standard for cloud infrastructure&lt;/strong&gt;.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Therefore, do not miss the chance to make the switch and be part of the new standard.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;aside class=&quot;post-cta&quot;&gt;
  &lt;h2 id=&quot;we-could-migrate-you&quot;&gt;We Could Migrate You&lt;/h2&gt;

  &lt;p&gt;If you are running EC2 workloads on x86 and the bill is starting to bite, this is the kind of engagement &lt;strong&gt;Clearview Team&lt;/strong&gt; takes on. We benchmark your workload on Graviton, plan the migration around your release cadence, and ship the Terraform that keeps you on ARM.&lt;/p&gt;

  &lt;p&gt;&lt;a href=&quot;/work-with-us/&quot;&gt;Scope an engagement →&lt;/a&gt;&lt;/p&gt;
&lt;/aside&gt;
</content>
    <category term="aws-cost-optimization" />
    <category term="cloud-computing" />
    <category term="arm" />
    <category term="aws-graviton" />
    <category term="aws" />
    <category term="aws-devops" />
    
  </entry>
  
  <entry>
    <title>Deploying Node Containers on AWS App Runner with Terraform</title>
    <link href="https://blog.clearview.team/2024/the-easy-and-smart-way-to-deploy-containers-on-the-cloud-say-hello-to-apprunner/" />
    <id>https://blog.clearview.team/2024/the-easy-and-smart-way-to-deploy-containers-on-the-cloud-say-hello-to-apprunner/</id>
    <published>2024-01-11T07:01:42+01:00</published>
    <updated>2024-01-11T07:01:42+01:00</updated>
    <author>
      <name>Nedim Hadzimahmutovic</name>
    </author>
    <summary>Why AWS App Runner is our default for container workloads — the `aws_apprunner_service` Terraform resource, ECR wiring, and the auto-scaling defaults we actually keep in production.</summary>
    <content type="html">&lt;h3 id=&quot;the-smart-way-to-deploy-containers-on-the-cloud&quot;&gt;The Smart Way to Deploy Containers on The Cloud&lt;/h3&gt;

&lt;h4 id=&quot;say-hello-to-aws-apprunner&quot;&gt;Say hello to AWS AppRunner&lt;/h4&gt;

&lt;p&gt;&lt;img src=&quot;/assets/images/posts/the-easy-and-smart-way-to-deploy-containers-on-the-cloud-say-hello-to-apprunner/cover.svg&quot; alt=&quot;Editorial cover — three container blocks (api, web, worker) resolving into a single AppRunner https endpoint, brand colours throughout.&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Containers on AWS AppRunner&lt;/p&gt;

&lt;aside class=&quot;callout callout--warning&quot;&gt;
  &lt;p&gt;&lt;strong&gt;Heads up — AppRunner is sunsetting.&lt;/strong&gt; As of &lt;strong&gt;April 30, 2026&lt;/strong&gt;, AWS App Runner no longer accepts new customers. Existing App Runner services keep running and AWS continues to invest in security and availability, but no new features are planned. For new container workloads, AWS recommends &lt;strong&gt;Amazon ECS Express Mode&lt;/strong&gt; — a new capability inside ECS designed to give you the same one-command deploy without the App Runner lock-in. This post stays up as a reference for the Terraform shape and the deployment thinking; if you are starting a new container workload today, reach for ECS Express Mode instead.&lt;/p&gt;
&lt;/aside&gt;

&lt;h3 id=&quot;why-apprunner&quot;&gt;Why AppRunner?&lt;/h3&gt;

&lt;h4 id=&quot;it-is-easy-to-deploy-and-manage&quot;&gt;It is easy to deploy and manage&lt;/h4&gt;

&lt;p&gt;If you tried to deploy containers on the cloud you have experienced anything but an easy process. It has been easy to deploy containers on your local machine but deploying them on the Cloud has not been an easy process.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;That was true until AWS AppRunner was introduced back in 2021.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h4 id=&quot;it-is-a-great-solution-for-startups&quot;&gt;It is a great solution for startups&lt;/h4&gt;

&lt;p&gt;Here at &lt;a href=&quot;https://clearview.team/&quot;&gt;&lt;strong&gt;Clearview&lt;/strong&gt;&lt;/a&gt;, we have found that AppRunner can be of very benefit to startup projects as it is cost-effective because of its on-demand pricing model and it is a fully managed service.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;This will result in less time spent on setting up the infrastructure which saves us time and money.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h4 id=&quot;it-has-an-attractive-pricing-model&quot;&gt;It has an attractive pricing model&lt;/h4&gt;

&lt;p&gt;What is great about AppRunner is that when the container is in an &lt;strong&gt;idle state&lt;/strong&gt; you only pay for the memory provisioned in each container instance. When a container is in the idle state it is considered to be a &lt;strong&gt;Provisioned container instance&lt;/strong&gt;. Once the container starts to use CPU resources, it will be considered an &lt;strong&gt;Active container instance&lt;/strong&gt; and you will be charged for the CPU resources used.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;As you can pause and resume the service you can as well additionally save on costs.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h4 id=&quot;in-summary&quot;&gt;In summary&lt;/h4&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Provisioned container instances&lt;/strong&gt; — charged for memory
($0.007 / GB-hour),&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Active container instances&lt;/strong&gt; — charged for both memory and CPU resources used
($0.064 / vCPU-hour, $0.007 / GB-hour),&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Paused instances&lt;/strong&gt;— CPU and memory are not charged.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;what-can-apprunner-do&quot;&gt;What can AppRunner do?&lt;/h3&gt;

&lt;p&gt;&lt;img src=&quot;/assets/images/posts/the-easy-and-smart-way-to-deploy-containers-on-the-cloud-say-hello-to-apprunner/features.svg&quot; alt=&quot;Four AppRunner features as cards: auto-scaling, builds from ECR or source, managed TLS, and integrated logs and metrics.&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Since AWS App Runner is a fully managed service it will&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;automatically deploy&lt;/strong&gt; web applications by detecting a new container image that has been pushed to the container registry,&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;automatically scale and load balance&lt;/strong&gt; to meet the traffic needs.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
  &lt;p&gt;Automatic deployment, scaling, and load balancing make it a desirable technology.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h4 id=&quot;easily-managed-by-terraform&quot;&gt;Easily Managed by Terraform&lt;/h4&gt;

&lt;p&gt;By adding Terraform to the mix we can provision this technology even faster. Later in this article, we will provide examples of code on how to set up AppRunner with Terraform.&lt;/p&gt;

&lt;h3 id=&quot;history&quot;&gt;History&lt;/h3&gt;

&lt;p&gt;Back when it was introduced it was still an early tech and still not developed and mature but in the last two years, it has grown significantly. Here are some improvements that we have witnessed while using the tech:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Added &lt;strong&gt;support for more regions&lt;/strong&gt; across the world,&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Reduced duration for deploying&lt;/strong&gt; applications using container images, which is about a 30–40% reduction in deployment time depending on the container image size,&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Increased instance startup time&lt;/strong&gt; from one to a maximum of five minutes which enables slower instances that need more time of startup to be used,&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Added dual stack&lt;/strong&gt; support for incoming traffic through public endpoints, both IPv4 and IPv6 endpoints, simultaneously,&lt;/li&gt;
  &lt;li&gt;Reduced the time taken for image-based service deployment,
Added support for immediate deployment failure if App Runner couldn’t pull an image.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;provisioning-apprunner-with-terraform&quot;&gt;Provisioning AppRunner with Terraform&lt;/h3&gt;

&lt;p&gt;&lt;img src=&quot;/assets/images/posts/the-easy-and-smart-way-to-deploy-containers-on-the-cloud-say-hello-to-apprunner/terraform.svg&quot; alt=&quot;Terraform plan on the left listing aws_ecr_repository, aws_iam_role, aws_apprunner_service and a custom domain; on the right a running AppRunner service with a green check next to its https endpoint.&quot; /&gt;&lt;/p&gt;

&lt;p&gt;In this demonstration, we will provision AWS services with Terraform as follows:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;ECR for hosting container images,&lt;/li&gt;
  &lt;li&gt;NodeJS API service on AppRunner,&lt;/li&gt;
  &lt;li&gt;NuxtJS FrontEnd service on AppRunner.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Local variables are defined at the top of the .tf file to be straightforward about what values should be changed depending on the project.&lt;/p&gt;

&lt;h4 id=&quot;diagram&quot;&gt;Diagram&lt;/h4&gt;

&lt;p&gt;In the diagram, it is clear that the publicly exposed service is the FrontEnd service, which then connects to the API service. To point and connect one service to the other environment variables are used.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/images/posts/the-easy-and-smart-way-to-deploy-containers-on-the-cloud-say-hello-to-apprunner/1_jbSglooJmw6b7lRUtXWipQ.png&quot; alt=&quot;AWS AppRunner architecture diagram&quot; /&gt;&lt;/p&gt;

&lt;h4 id=&quot;provision-ecr&quot;&gt;Provision ECR&lt;/h4&gt;

&lt;p&gt;This is an example of how to provision the ECR for API.&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://github.com/clearview/aws_apprunner_service_with_terraform/blob/e4c96bc5e856753515c2c640e853f0deeef5e71f/ecr.tf#L1-L36&quot;&gt;aws_apprunner_service_with_terraform/ecr.tf at e4c96bc5e856753515c2c640e853f0deeef5e71f · clearview/aws_apprunner_service_with_terraform&lt;/a&gt;&lt;/p&gt;

&lt;div class=&quot;language-hcl highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;# my_api&lt;/span&gt;
&lt;span class=&quot;nx&quot;&gt;resource&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;aws_ecr_repository&quot;&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;my_ecr_api&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;name&lt;/span&gt;                 &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;my_ecr/my_api&quot;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;image_tag_mutability&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;MUTABLE&quot;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;image_scanning_configuration&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;scan_on_push&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;nx&quot;&gt;resource&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;aws_ecr_lifecycle_policy&quot;&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;my_ecr_api&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;repository&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;aws_ecr_repository&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;my_ecr_api&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;name&lt;/span&gt;

  &lt;span class=&quot;nx&quot;&gt;policy&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;jsonencode&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;rules&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[{&lt;/span&gt;
      &lt;span class=&quot;nx&quot;&gt;rulePriority&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;
      &lt;span class=&quot;nx&quot;&gt;description&lt;/span&gt;  &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;keep last 10 images&quot;&lt;/span&gt;
      &lt;span class=&quot;nx&quot;&gt;action&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;nx&quot;&gt;type&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;expire&quot;&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
      &lt;span class=&quot;nx&quot;&gt;selection&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;nx&quot;&gt;tagStatus&lt;/span&gt;   &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;any&quot;&lt;/span&gt;
        &lt;span class=&quot;nx&quot;&gt;countType&lt;/span&gt;   &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;imageCountMoreThan&quot;&lt;/span&gt;
        &lt;span class=&quot;nx&quot;&gt;countNumber&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;10&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}]&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;})&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;nx&quot;&gt;output&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;my_ecr_api_registry_id&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;value&lt;/span&gt;       &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;aws_ecr_repository&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;my_ecr_api&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;registry_id&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;description&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&quot;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;nx&quot;&gt;output&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;my_ecr_api_repository_url&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;value&lt;/span&gt;       &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;aws_ecr_repository&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;my_ecr_api&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;repository_url&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;description&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&quot;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h4 id=&quot;provision-of-the-api-service&quot;&gt;Provision of the API Service&lt;/h4&gt;

&lt;p&gt;In this example, the health check is performed against a custom endpoint available on the/api/healtcheck URN, but that might be different in your case.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;To be safe and if you do not have a health check endpoint yet just use / .&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This instance is provisioned with the following configuration:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;1024 CPU units,&lt;/li&gt;
  &lt;li&gt;2048 MB of memory,&lt;/li&gt;
  &lt;li&gt;The health check endpoint is located at /api/healthcheck ,&lt;/li&gt;
  &lt;li&gt;Container port running on 3000 .&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href=&quot;https://github.com/clearview/aws_apprunner_service_with_terraform/blob/e4c96bc5e856753515c2c640e853f0deeef5e71f/api_service.tf#L1-L75&quot;&gt;aws_apprunner_service_with_terraform/api_service.tf at e4c96bc5e856753515c2c640e853f0deeef5e71f · clearview/aws_apprunner_service_with_terraform&lt;/a&gt;&lt;/p&gt;

&lt;div class=&quot;language-hcl highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nx&quot;&gt;locals&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;api_port&lt;/span&gt;                   &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;3000&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;api_domain&lt;/span&gt;                 &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;api.mydomain.team&quot;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;api_git_repo&lt;/span&gt;               &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;my/my_api&quot;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;api_development_git_branch&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;dev&quot;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;api_ecr_image_development&lt;/span&gt;  &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;${aws_ecr_repository.my_api_ecr.repository_url}:dev&quot;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;nx&quot;&gt;resource&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;aws_apprunner_service&quot;&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;my_api&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;

  &lt;span class=&quot;nx&quot;&gt;depends_on&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;aws_ecr_repository&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;my_api_ecr&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;aws_iam_role&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;my_app_runner_roles&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;aws_apprunner_vpc_connector&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;my_vpc_connector&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;

  &lt;span class=&quot;nx&quot;&gt;service_name&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;my_api&quot;&lt;/span&gt;

  &lt;span class=&quot;nx&quot;&gt;source_configuration&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;authentication_configuration&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;nx&quot;&gt;access_role_arn&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;aws_iam_role&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;my_app_runner_roles&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;arn&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;nx&quot;&gt;image_repository&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;nx&quot;&gt;image_identifier&lt;/span&gt;      &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;local&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;api_ecr_image_development&lt;/span&gt;
      &lt;span class=&quot;nx&quot;&gt;image_repository_type&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;ECR&quot;&lt;/span&gt;
      &lt;span class=&quot;nx&quot;&gt;image_configuration&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;nx&quot;&gt;port&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;3000&lt;/span&gt;
        &lt;span class=&quot;nx&quot;&gt;runtime_environment_variables&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
          &lt;span class=&quot;c1&quot;&gt;# Server Port&lt;/span&gt;
          &lt;span class=&quot;nx&quot;&gt;PORT&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;3000&quot;&lt;/span&gt;
          &lt;span class=&quot;c1&quot;&gt;#&lt;/span&gt;
          &lt;span class=&quot;nx&quot;&gt;NODE_ENV&lt;/span&gt;       &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;development&quot;&lt;/span&gt;
          &lt;span class=&quot;nx&quot;&gt;LOG_LEVEL&lt;/span&gt;      &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;debug&quot;&lt;/span&gt;
          &lt;span class=&quot;nx&quot;&gt;PORT&lt;/span&gt;           &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;local&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;api_port&lt;/span&gt;
          &lt;span class=&quot;nx&quot;&gt;CONTAINER_PORT&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;local&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;api_port&lt;/span&gt;
          &lt;span class=&quot;nx&quot;&gt;HOST_PORT&lt;/span&gt;      &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;local&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;api_port&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;nx&quot;&gt;runtime_environment_secrets&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;auto_deployments_enabled&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

  &lt;span class=&quot;nx&quot;&gt;health_check_configuration&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;path&lt;/span&gt;                &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;/api/healthcheck&quot;&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;healthy_threshold&lt;/span&gt;   &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;interval&lt;/span&gt;            &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;5&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;protocol&lt;/span&gt;            &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;HTTP&quot;&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;timeout&lt;/span&gt;             &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;20&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;unhealthy_threshold&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;20&lt;/span&gt;
  &lt;span class=&quot;err&quot;&gt;}&lt;/span&gt;

  &lt;span class=&quot;nx&quot;&gt;instance_configuration&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;cpu&lt;/span&gt;    &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;2048&quot;&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;memory&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;4096&quot;&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

  &lt;span class=&quot;nx&quot;&gt;network_configuration&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;egress_configuration&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;nx&quot;&gt;egress_type&lt;/span&gt;       &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;VPC&quot;&lt;/span&gt;
      &lt;span class=&quot;nx&quot;&gt;vpc_connector_arn&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;aws_apprunner_vpc_connector&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;my_vpc_connector&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;arn&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

  &lt;span class=&quot;nx&quot;&gt;tags&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;Name&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;my-my_api-apprunner-service&quot;&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;nx&quot;&gt;output&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;my_api_apprunner_my_api_service_url&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;value&lt;/span&gt;       &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;aws_apprunner_service&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;my_api&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;service_url&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;description&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&quot;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h4 id=&quot;provision-of-the-frontend-service&quot;&gt;Provision of the FrontEnd service&lt;/h4&gt;

&lt;p&gt;This instance is provisioned with the following configuration:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;1024 CPU units,&lt;/li&gt;
  &lt;li&gt;2048 MB of memory,&lt;/li&gt;
  &lt;li&gt;The health check endpoint is located at /ping .&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href=&quot;https://github.com/clearview/aws_apprunner_service_with_terraform/blob/e4c96bc5e856753515c2c640e853f0deeef5e71f/fe_service.tf#L1-L91&quot;&gt;aws_apprunner_service_with_terraform/fe_service.tf at e4c96bc5e856753515c2c640e853f0deeef5e71f · clearview/aws_apprunner_service_with_terraform&lt;/a&gt;&lt;/p&gt;

&lt;div class=&quot;language-hcl highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nx&quot;&gt;locals&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;my_fe_port&lt;/span&gt;                   &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;3001&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;my_fe_domain&lt;/span&gt;                 &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;frontend.my-domain.org&quot;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;my_fe_apprunner_domain&lt;/span&gt;       &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;members.development.my-domain.org&quot;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;my_fe_ecr_image_development&lt;/span&gt;  &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;${aws_ecr_repository.my_fe_ecr.repository_url}:dev&quot;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;my_fe_git_repo&lt;/span&gt;               &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;clearview/my-members-fe&quot;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;my_fe_development_git_branch&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;dev&quot;&lt;/span&gt;
&lt;span class=&quot;err&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;nx&quot;&gt;resource&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;aws_apprunner_service&quot;&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;my_fe&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;depends_on&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;aws_ecr_repository&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;my_fe_ecr&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;

  &lt;span class=&quot;nx&quot;&gt;service_name&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;my_fe&quot;&lt;/span&gt;

  &lt;span class=&quot;nx&quot;&gt;source_configuration&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;authentication_configuration&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;nx&quot;&gt;access_role_arn&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;aws_iam_role&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;my_app_runner_roles&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;arn&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;nx&quot;&gt;image_repository&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;nx&quot;&gt;image_identifier&lt;/span&gt;      &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;local&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;my_fe_ecr_image_development&lt;/span&gt;
      &lt;span class=&quot;nx&quot;&gt;image_repository_type&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;ECR&quot;&lt;/span&gt;
      &lt;span class=&quot;nx&quot;&gt;image_configuration&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;nx&quot;&gt;port&lt;/span&gt;          &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;local&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;my_fe_port&lt;/span&gt;
        &lt;span class=&quot;nx&quot;&gt;start_command&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;yarn dev&quot;&lt;/span&gt;
        &lt;span class=&quot;nx&quot;&gt;runtime_environment_variables&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
          &lt;span class=&quot;nx&quot;&gt;HOST&lt;/span&gt;           &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;0.0.0.0&quot;&lt;/span&gt;
          &lt;span class=&quot;nx&quot;&gt;HOSTNAME&lt;/span&gt;       &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;0.0.0.0&quot;&lt;/span&gt;
          &lt;span class=&quot;nx&quot;&gt;NITRO_HOST&lt;/span&gt;     &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;0.0.0.0&quot;&lt;/span&gt;
          &lt;span class=&quot;nx&quot;&gt;ENV&lt;/span&gt;            &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;development&quot;&lt;/span&gt;
          &lt;span class=&quot;nx&quot;&gt;NODE_ENV&lt;/span&gt;       &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;development&quot;&lt;/span&gt;
          &lt;span class=&quot;nx&quot;&gt;PORT&lt;/span&gt;           &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;local&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;my_fe_port&lt;/span&gt;
          &lt;span class=&quot;nx&quot;&gt;NITRO_PORT&lt;/span&gt;     &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;local&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;my_fe_port&lt;/span&gt;
          &lt;span class=&quot;nx&quot;&gt;CONTAINER_PORT&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;local&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;my_fe_port&lt;/span&gt;
          &lt;span class=&quot;nx&quot;&gt;HOST_PORT&lt;/span&gt;      &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;local&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;my_fe_port&lt;/span&gt;
          &lt;span class=&quot;c1&quot;&gt;# API&lt;/span&gt;
          &lt;span class=&quot;nx&quot;&gt;PROXY_TARGET&lt;/span&gt;      &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;https://${local.api_domain}&quot;&lt;/span&gt;
          &lt;span class=&quot;nx&quot;&gt;NUXT_API_BASE_URL&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;https://${local.api_domain}&quot;&lt;/span&gt;
          &lt;span class=&quot;c1&quot;&gt;# Nuxt&lt;/span&gt;
          &lt;span class=&quot;nx&quot;&gt;NUXT_PUBLIC_DEV&lt;/span&gt;      &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;
          &lt;span class=&quot;nx&quot;&gt;NUXT_PUBLIC_DEBUG&lt;/span&gt;    &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;
          &lt;span class=&quot;nx&quot;&gt;NUXT_PUBLIC_SITE_URL&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;http://localhost:${local.my_fe_port}&quot;&lt;/span&gt;
          &lt;span class=&quot;nx&quot;&gt;NUXT_PORT&lt;/span&gt;            &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;local&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;my_fe_port&lt;/span&gt;
          &lt;span class=&quot;nx&quot;&gt;NUXT_HOST&lt;/span&gt;            &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;0.0.0.0&quot;&lt;/span&gt;
          &lt;span class=&quot;c1&quot;&gt;# Debug&lt;/span&gt;
          &lt;span class=&quot;nx&quot;&gt;LOG_LEVEL&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;debug&quot;&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;auto_deployments_enabled&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

  &lt;span class=&quot;nx&quot;&gt;health_check_configuration&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;path&lt;/span&gt;                &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;/ping&quot;&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;healthy_threshold&lt;/span&gt;   &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;interval&lt;/span&gt;            &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;20&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;protocol&lt;/span&gt;            &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;HTTP&quot;&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;timeout&lt;/span&gt;             &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;19&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;unhealthy_threshold&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;20&lt;/span&gt;
  &lt;span class=&quot;err&quot;&gt;}&lt;/span&gt;

  &lt;span class=&quot;nx&quot;&gt;instance_configuration&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;cpu&lt;/span&gt;    &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;1024&quot;&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;memory&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;2048&quot;&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

  &lt;span class=&quot;nx&quot;&gt;network_configuration&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;egress_configuration&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;nx&quot;&gt;egress_type&lt;/span&gt;       &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;VPC&quot;&lt;/span&gt;
      &lt;span class=&quot;nx&quot;&gt;vpc_connector_arn&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;aws_apprunner_vpc_connector&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;my_vpc_connector&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;arn&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;nx&quot;&gt;ingress_configuration&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;nx&quot;&gt;is_publicly_accessible&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

  &lt;span class=&quot;nx&quot;&gt;tags&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;Name&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;my-my_fe-apprunner-service&quot;&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;nx&quot;&gt;output&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;my_fe_apprunner_my_fe_service_url&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;value&lt;/span&gt;       &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;aws_apprunner_service&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;my_fe&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;service_url&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;description&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&quot;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h3&gt;

&lt;p&gt;In this article, we covered AppRunner from its humble beginnings to the powerful technology it has proven to be today. We provided useful diagrams and Terraform code for easy provisioning of service.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;If you have any questions please do not hesitate to contact our team.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;aside class=&quot;post-cta&quot;&gt;
  &lt;h2 id=&quot;we-could-move-you-off-apprunner-cleanly&quot;&gt;We Could Move You Off AppRunner Cleanly&lt;/h2&gt;

  &lt;p&gt;If you have App Runner in production today and the April 2026 sunset notice has you wondering what to do, that is the engagement &lt;strong&gt;Clearview Team&lt;/strong&gt; has in front of us right now. The target is ECS Express Mode, the Terraform stays small, and we cut over in stages so your traffic does not notice. We have done the same shape of work moving teams off Fargate behind a bespoke ALB and off self-managed Kubernetes — the muscle memory carries.&lt;/p&gt;

  &lt;p&gt;&lt;a href=&quot;mailto:info@clearview.team?subject=AppRunner%20to%20ECS%20Express%20Mode%20migration&quot;&gt;Brief us on your AppRunner stack →&lt;/a&gt;&lt;/p&gt;
&lt;/aside&gt;
</content>
    <category term="aws-app-runner" />
    <category term="cloud" />
    <category term="containers" />
    <category term="nodejs" />
    <category term="nuxtjs" />
    <category term="aws-devops" />
    
  </entry>
  
  <entry>
    <title>AWS Budgets and Alerts with Terraform: A Cost-Monitoring Case Study</title>
    <link href="https://blog.clearview.team/2024/before-setting-up-your-next-cloud-project-do-not-forget-to-set-the-budgets-and-alerts/" />
    <id>https://blog.clearview.team/2024/before-setting-up-your-next-cloud-project-do-not-forget-to-set-the-budgets-and-alerts/</id>
    <published>2024-01-03T13:47:03+01:00</published>
    <updated>2024-01-03T13:47:03+01:00</updated>
    <author>
      <name>Nedim Hadzimahmutovic</name>
    </author>
    <summary>How we set AWS budgets and Slack alerts with Terraform — the `aws_budgets_budget` notification block, its `subscriber_email_addresses`, and how to route a real breach into SNS before the invoice hits the CFO&apos;s inbox.</summary>
    <content type="html">&lt;h3 id=&quot;monitor-and-control-amazon-cloud-costs-with-terraform&quot;&gt;Monitor and Control Amazon Cloud Costs with Terraform&lt;/h3&gt;

&lt;h4 id=&quot;for-every-cloud-project-you-need-to-set-budgets-and-alerts-with-terraform&quot;&gt;For every Cloud project, you need to set budgets and alerts with Terraform&lt;/h4&gt;

&lt;figure class=&quot;post-figure&quot;&gt;
  &lt;img src=&quot;/assets/images/posts/before-setting-up-your-next-cloud-project-do-not-forget-to-set-the-budgets-and-alerts/cover.svg&quot; width=&quot;1600&quot; height=&quot;900&quot; alt=&quot;AWS Budgets with Terraform — descending cost line with budget cap and alert dot&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; /&gt;
  &lt;figcaption&gt;AWS Budgets with Terraform&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;h3 id=&quot;why-set-up-a-budget-for-your-cloud-account&quot;&gt;Why set up a budget for your cloud account?&lt;/h3&gt;

&lt;p&gt;If you wonder why most startups fail it is simple, they run out of money and there have been many horror stories about crazy big cloud bills.&lt;/p&gt;

&lt;p&gt;At &lt;a href=&quot;https://clearview.team/&quot;&gt;&lt;strong&gt;&lt;em&gt;Clearview&lt;/em&gt;&lt;/strong&gt;&lt;/a&gt;, when we put a new client on the Cloud, we know they will get a bill eventually. It’s our responsibility to make sure that the bill will not be a big and sudden surprise.&lt;/p&gt;

&lt;h3 id=&quot;setting-up-the-budgets&quot;&gt;Setting up the budgets&lt;/h3&gt;

&lt;figure class=&quot;post-figure&quot;&gt;
  &lt;img src=&quot;/assets/images/posts/before-setting-up-your-next-cloud-project-do-not-forget-to-set-the-budgets-and-alerts/setting-up.svg&quot; width=&quot;1200&quot; height=&quot;420&quot; alt=&quot;Four required pieces of an AWS budget — name, limit amount, period, alert threshold&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; /&gt;
  &lt;figcaption&gt;Setting the AWS Cloud budget&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;The first step is to set budgets and alerts. We usually set:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The Account wide budget,&lt;/li&gt;
  &lt;li&gt;The Most used service budget,&lt;/li&gt;
  &lt;li&gt;The Most important tag budget.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every budget sends out an email notification when the budget cost threshold is reached, in these two cases:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;The ACTUAL cost&lt;/strong&gt;
which will be triggered once that amount has already been spent,&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The FORECASTED cost&lt;/strong&gt;
notification will be triggered earlier as the cost is based on prediction based on your past usage.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;As we are a tech company the budgets are set using &lt;strong&gt;Terraform&lt;/strong&gt; and in the next section, you can find diagrams and code samples that explain how budgets and notifications work.&lt;/p&gt;

&lt;h3 id=&quot;monthly-account-budget&quot;&gt;Monthly Account Budget&lt;/h3&gt;

&lt;figure class=&quot;post-figure&quot;&gt;
  &lt;img src=&quot;/assets/images/posts/before-setting-up-your-next-cloud-project-do-not-forget-to-set-the-budgets-and-alerts/monthly-cap.svg&quot; width=&quot;1200&quot; height=&quot;420&quot; alt=&quot;Monthly account budget with three alert thresholds at 50, 80, and 100 percent&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; /&gt;
  &lt;figcaption&gt;Set your Monthly AWS Budget.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;This budget tracks account-wide costs.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/images/posts/before-setting-up-your-next-cloud-project-do-not-forget-to-set-the-budgets-and-alerts/1_DjYvXzclbSKPmda0FHPUWQ.png&quot; alt=&quot;AWS budget configuration diagram&quot; /&gt;&lt;/p&gt;

&lt;div class=&quot;language-hcl highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nx&quot;&gt;resource&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;aws_budgets_budget&quot;&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;monthly_account_budget&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;name&lt;/span&gt;         &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Monthly Budget for my Account&quot;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;budget_type&lt;/span&gt;  &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;COST&quot;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;limit_amount&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;500&quot;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;limit_unit&lt;/span&gt;   &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;USD&quot;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;time_unit&lt;/span&gt;    &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;MONTHLY&quot;&lt;/span&gt;

  &lt;span class=&quot;nx&quot;&gt;notification&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;comparison_operator&lt;/span&gt;        &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;GREATER_THAN&quot;&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;threshold&lt;/span&gt;                  &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;90&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;threshold_type&lt;/span&gt;             &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;PERCENTAGE&quot;&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;notification_type&lt;/span&gt;          &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;ACTUAL&quot;&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;subscriber_email_addresses&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;email@my.team&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

  &lt;span class=&quot;nx&quot;&gt;notification&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;comparison_operator&lt;/span&gt;        &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;GREATER_THAN&quot;&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;threshold&lt;/span&gt;                  &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;100&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;threshold_type&lt;/span&gt;             &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;PERCENTAGE&quot;&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;notification_type&lt;/span&gt;          &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;FORECASTED&quot;&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;subscriber_email_addresses&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;email@my.team&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href=&quot;https://github.com/clearview/aws_budgets_notifications_with_terraform/blob/0b8af27933b32c27c7b09c4b633724ed17fe82b9/monthly_account_budget.tf#L1-L23&quot;&gt;aws_budgets_notifications_with_terraform/monthly_account_budget.tf at 0b8af27933b32c27c7b09c4b633724ed17fe82b9 · clearview/aws_budgets_notifications_with_terraform&lt;/a&gt;&lt;/p&gt;

&lt;h4 id=&quot;the-notification-block-arguments&quot;&gt;The notification block arguments&lt;/h4&gt;

&lt;p&gt;Each &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;notification&lt;/code&gt; block inside &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;aws_budgets_budget&lt;/code&gt; controls one
alert. The five arguments the AWS provider expects are:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;comparison_operator&lt;/code&gt;&lt;/strong&gt; — one of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GREATER_THAN&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;LESS_THAN&lt;/code&gt;, or
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;EQUAL_TO&lt;/code&gt;. For a spend budget you almost always use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GREATER_THAN&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;threshold&lt;/code&gt;&lt;/strong&gt; — a number. Compared against the budgeted amount.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;threshold_type&lt;/code&gt;&lt;/strong&gt; — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PERCENTAGE&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ABSOLUTE_VALUE&lt;/code&gt;. Percentage
is easier to reason about because you don&apos;t have to rewrite the
block when the budget limit changes.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;notification_type&lt;/code&gt;&lt;/strong&gt; — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ACTUAL&lt;/code&gt; (the current spend) or
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FORECASTED&lt;/code&gt; (AWS&apos;s prediction based on past usage). Set one of
each per budget so you get both an early warning and a real breach
alert.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;subscriber_email_addresses&lt;/code&gt;&lt;/strong&gt; — a &lt;strong&gt;list of email strings&lt;/strong&gt;.
This is where the alert lands. Every address in the list receives
a copy. Note: it is a Terraform list, not a comma-separated
string, so use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[&quot;a@x&quot;, &quot;b@x&quot;]&lt;/code&gt; shape not &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&quot;a@x, b@x&quot;&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There are two adjacent arguments that this example does not use but
you might reach for:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;subscriber_sns_topic_arns&lt;/code&gt;&lt;/strong&gt; — send the notification to an SNS
topic instead of (or in addition to) email. Useful when you want
the alert to fan out into Slack via an SNS→Lambda hop.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;subscriber_type&lt;/code&gt;&lt;/strong&gt; on the older &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;notification&lt;/code&gt; shape has been
replaced by the three &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;subscriber_*&lt;/code&gt; list arguments; you should
not need it on modern provider versions (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;hashicorp/aws &amp;gt;= 4.0&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Full provider docs: &lt;a href=&quot;https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/budgets_budget#notification&quot;&gt;aws_budgets_budget#notification&lt;/a&gt;.&lt;/p&gt;

&lt;h3 id=&quot;budgets-by-service&quot;&gt;Budgets by Service&lt;/h3&gt;

&lt;figure class=&quot;post-figure&quot;&gt;
  &lt;img src=&quot;/assets/images/posts/before-setting-up-your-next-cloud-project-do-not-forget-to-set-the-budgets-and-alerts/by-service.svg&quot; width=&quot;1200&quot; height=&quot;420&quot; alt=&quot;Budgets scoped per AWS service — EC2, S3, RDS, AppRunner&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; /&gt;
  &lt;figcaption&gt;AWS Budgets illustration.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;h4 id=&quot;ec2-monthly-budget&quot;&gt;EC2 Monthly Budget&lt;/h4&gt;

&lt;p&gt;This budget will track costs for EC2 services only.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/images/posts/before-setting-up-your-next-cloud-project-do-not-forget-to-set-the-budgets-and-alerts/1_nB9REBXD-JJqMweyPN5ReQ.png&quot; alt=&quot;AWS budget configuration diagram&quot; /&gt;&lt;/p&gt;

&lt;div class=&quot;language-hcl highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nx&quot;&gt;resource&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;aws_budgets_budget&quot;&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;ec2_monthly_budget&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;name&lt;/span&gt;         &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;My EC2 Monthly Budget&quot;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;budget_type&lt;/span&gt;  &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;COST&quot;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;limit_amount&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;400&quot;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;limit_unit&lt;/span&gt;   &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;USD&quot;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;time_unit&lt;/span&gt;    &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;MONTHLY&quot;&lt;/span&gt;

  &lt;span class=&quot;nx&quot;&gt;cost_filter&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;name&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Service&quot;&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;values&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
      &lt;span class=&quot;s2&quot;&gt;&quot;Amazon Elastic Compute Cloud - Compute&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

  &lt;span class=&quot;nx&quot;&gt;notification&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;comparison_operator&lt;/span&gt;        &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;GREATER_THAN&quot;&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;threshold&lt;/span&gt;                  &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;90&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;threshold_type&lt;/span&gt;             &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;PERCENTAGE&quot;&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;notification_type&lt;/span&gt;          &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;ACTUAL&quot;&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;subscriber_email_addresses&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;email@my.team&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

  &lt;span class=&quot;nx&quot;&gt;notification&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;comparison_operator&lt;/span&gt;        &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;GREATER_THAN&quot;&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;threshold&lt;/span&gt;                  &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;100&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;threshold_type&lt;/span&gt;             &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;PERCENTAGE&quot;&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;notification_type&lt;/span&gt;          &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;FORECASTED&quot;&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;subscriber_email_addresses&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;email@my.team&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href=&quot;https://github.com/clearview/aws_budgets_notifications_with_terraform/blob/0b8af27933b32c27c7b09c4b633724ed17fe82b9/ec2_monthly_account_budget.tf#L1-L30&quot;&gt;aws_budgets_notifications_with_terraform/ec2_monthly_account_budget.tf at 0b8af27933b32c27c7b09c4b633724ed17fe82b9 · clearview/aws_budgets_notifications_with_terraform&lt;/a&gt;&lt;/p&gt;

&lt;h4 id=&quot;s3-monthly-budget&quot;&gt;S3 Monthly Budget&lt;/h4&gt;

&lt;figure class=&quot;post-figure&quot;&gt;
  &lt;img src=&quot;/assets/images/posts/before-setting-up-your-next-cloud-project-do-not-forget-to-set-the-budgets-and-alerts/s3-budget.svg&quot; width=&quot;1200&quot; height=&quot;380&quot; alt=&quot;S3 monthly budget with usage bar and 75 percent alert marker&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; /&gt;
  &lt;figcaption&gt;AWS S3 Monthly Budget.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;This budget will track costs for S3 buckets only.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/images/posts/before-setting-up-your-next-cloud-project-do-not-forget-to-set-the-budgets-and-alerts/1_6k0O3vCONWjcWbY-5mLdIw.png&quot; alt=&quot;AWS budget configuration diagram&quot; /&gt;&lt;/p&gt;

&lt;div class=&quot;language-hcl highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nx&quot;&gt;resource&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;aws_budgets_budget&quot;&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;s3_monthly_budget&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;name&lt;/span&gt;         &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;My S3 Monthly Budget&quot;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;budget_type&lt;/span&gt;  &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;COST&quot;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;limit_amount&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;100&quot;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;limit_unit&lt;/span&gt;   &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;USD&quot;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;time_unit&lt;/span&gt;    &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;MONTHLY&quot;&lt;/span&gt;

  &lt;span class=&quot;nx&quot;&gt;cost_filter&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;name&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Service&quot;&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;values&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
      &lt;span class=&quot;s2&quot;&gt;&quot;Amazon Simple Storage Service&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

  &lt;span class=&quot;nx&quot;&gt;notification&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;comparison_operator&lt;/span&gt;        &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;GREATER_THAN&quot;&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;threshold&lt;/span&gt;                  &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;90&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;threshold_type&lt;/span&gt;             &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;PERCENTAGE&quot;&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;notification_type&lt;/span&gt;          &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;ACTUAL&quot;&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;subscriber_email_addresses&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;email@my.team&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

  &lt;span class=&quot;nx&quot;&gt;notification&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;comparison_operator&lt;/span&gt;        &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;GREATER_THAN&quot;&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;threshold&lt;/span&gt;                  &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;110&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;threshold_type&lt;/span&gt;             &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;PERCENTAGE&quot;&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;notification_type&lt;/span&gt;          &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;FORECASTED&quot;&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;subscriber_email_addresses&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;email@my.team&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href=&quot;https://github.com/clearview/aws_budgets_notifications_with_terraform/blob/0b8af27933b32c27c7b09c4b633724ed17fe82b9/s3_monthly_account_budget.tf#L1-L30&quot;&gt;aws_budgets_notifications_with_terraform/s3_monthly_account_budget.tf at 0b8af27933b32c27c7b09c4b633724ed17fe82b9 · clearview/aws_budgets_notifications_with_terraform&lt;/a&gt;&lt;/p&gt;

&lt;h4 id=&quot;apprunner-monthly-budget&quot;&gt;AppRunner Monthly Budget&lt;/h4&gt;

&lt;p&gt;This budget will track costs for AppRunner services only.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/images/posts/before-setting-up-your-next-cloud-project-do-not-forget-to-set-the-budgets-and-alerts/1_bhPgTlQFCHCZI_ScAe8BMg.png&quot; alt=&quot;AWS budget configuration diagram&quot; /&gt;&lt;/p&gt;

&lt;div class=&quot;language-hcl highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nx&quot;&gt;resource&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;aws_budgets_budget&quot;&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;apprunner_monthly_budget&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;name&lt;/span&gt;         &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;My AppRunner Monthly Budget&quot;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;budget_type&lt;/span&gt;  &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;COST&quot;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;limit_amount&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;100&quot;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;limit_unit&lt;/span&gt;   &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;USD&quot;&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;time_unit&lt;/span&gt;    &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;MONTHLY&quot;&lt;/span&gt;

  &lt;span class=&quot;nx&quot;&gt;cost_filter&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;name&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Service&quot;&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;values&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
      &lt;span class=&quot;s2&quot;&gt;&quot;AWS App Runner&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

  &lt;span class=&quot;nx&quot;&gt;notification&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;comparison_operator&lt;/span&gt;        &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;GREATER_THAN&quot;&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;threshold&lt;/span&gt;                  &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;90&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;threshold_type&lt;/span&gt;             &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;PERCENTAGE&quot;&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;notification_type&lt;/span&gt;          &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;ACTUAL&quot;&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;subscriber_email_addresses&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;email@my.team&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

  &lt;span class=&quot;nx&quot;&gt;notification&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;comparison_operator&lt;/span&gt;        &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;GREATER_THAN&quot;&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;threshold&lt;/span&gt;                  &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;110&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;threshold_type&lt;/span&gt;             &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;PERCENTAGE&quot;&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;notification_type&lt;/span&gt;          &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;FORECASTED&quot;&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;subscriber_email_addresses&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;email@my.team&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href=&quot;https://github.com/clearview/aws_budgets_notifications_with_terraform/blob/0b8af27933b32c27c7b09c4b633724ed17fe82b9/apprunner_monthly_account_budget.tf#L1-L30&quot;&gt;aws_budgets_notifications_with_terraform/apprunner_monthly_account_budget.tf at 0b8af27933b32c27c7b09c4b633724ed17fe82b9 · clearview/aws_budgets_notifications_with_terraform&lt;/a&gt;&lt;/p&gt;

&lt;h3 id=&quot;budgets-based-on-tag-filtering&quot;&gt;Budgets based on tag filtering&lt;/h3&gt;

&lt;h4 id=&quot;production-tag-monthly-budget&quot;&gt;Production Tag Monthly Budget&lt;/h4&gt;

&lt;p&gt;This budget will group multiple types of service based on the tag, in this case, the production tag is used.&lt;/p&gt;

&lt;figure class=&quot;post-figure&quot;&gt;
  &lt;img src=&quot;/assets/images/posts/before-setting-up-your-next-cloud-project-do-not-forget-to-set-the-budgets-and-alerts/1_DpawlPqyEVxauvxdWVgT8w.png&quot; width=&quot;1024&quot; height=&quot;585&quot; alt=&quot;Terraform configuration for a production-tag monthly AWS budget&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; /&gt;
  &lt;figcaption&gt;&lt;a href=&quot;https://github.com/clearview/aws_budgets_notifications_with_terraform/blob/0b8af27933b32c27c7b09c4b633724ed17fe82b9/production_tag_monthly_budget.tf#L1-L36&quot;&gt;production_tag_monthly_budget.tf on GitHub&lt;/a&gt;&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;h3 id=&quot;links&quot;&gt;Links&lt;/h3&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/clearview/aws_budgets_notifications_with_terraform&quot;&gt;GitHub repo&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;closing&quot;&gt;Closing&lt;/h3&gt;

&lt;p&gt;This article explained the importance of setting a cloud budget and then demonstrated how to do it programmatically. Not only that, we went a step further and created nice diagrams for each budget so that it is easier for every reader to imagine the setup.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;We hope this article finds its way and helps you avoid unnecessary cloud costs.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;aside class=&quot;post-cta&quot;&gt;
  &lt;h2 id=&quot;we-could-set-this-up-for-your-team&quot;&gt;We Could Set This Up For Your Team&lt;/h2&gt;

  &lt;p&gt;If you are starting a new cloud project — or running one without budgets and alerts — this is the kind of engagement &lt;strong&gt;Clearview Team&lt;/strong&gt; takes on. We wire the budgets, the Slack alerts, and the Terraform that keeps every account on a leash, before the surprise invoice arrives.&lt;/p&gt;

  &lt;p&gt;&lt;a href=&quot;/work-with-us/&quot;&gt;Scope an engagement →&lt;/a&gt;&lt;/p&gt;
&lt;/aside&gt;
</content>
    <category term="budget" />
    <category term="cloud-computing" />
    <category term="cloud-services" />
    <category term="alerts" />
    <category term="cloud" />
    <category term="aws-cost-optimization" />
    <category term="aws-devops" />
    
  </entry>
  
</feed>
