<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://blog.clearview.team/feed.xml" rel="self" type="application/atom+xml" /><link href="https://blog.clearview.team/" rel="alternate" type="text/html" /><updated>2026-08-20T17:39:48+02:00</updated><id>https://blog.clearview.team/feed.xml</id><title type="html">Remote Since Forever</title><subtitle>Long-form essays from Clearview Team — on building software, shipping with small teams, and seeing past the noise.</subtitle><author><name>Clearview Team</name><email>info@clearview.team</email></author><entry><title type="html">Upgrading TypeORM 0.3 to 1.0 in Production: A NestJS Case Study</title><link href="https://blog.clearview.team/2026/the-typeorm-1-0-upgrade-that-did-not-block-production/" rel="alternate" type="text/html" title="Upgrading TypeORM 0.3 to 1.0 in Production: A NestJS Case Study" /><published>2026-08-17T11:00:00+02:00</published><updated>2026-08-17T11:00:00+02:00</updated><id>https://blog.clearview.team/2026/the-typeorm-1-0-upgrade-that-did-not-block-production</id><content type="html" xml:base="https://blog.clearview.team/2026/the-typeorm-1-0-upgrade-that-did-not-block-production/"><![CDATA[<p>A backend service I work on runs on TypeORM. Earlier this year we jumped from 0.x to the new 1.0 release. The old line had gone almost five years without a breaking change, and a class of quietly-wrong-answer bugs had piled up in it: queries that returned bad data instead of raising an error. The 1.0 release fixes them by making the bad queries fail loudly instead. It also forced an <code class="language-plaintext highlighter-rouge">@nestjs/typeorm</code> upgrade at the same time, since the NestJS glue layer for TypeORM crashes at startup on the new TypeORM if you are still on the old version. But the catch with any major ORM upgrade is that every entity, every query builder, every database factory in the test suite gets touched.</p>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  <p><a href="mailto:info@clearview.team?subject=Notification%20permission%20enquiry">Brief us on your permission flow →</a></p>
</aside>]]></content><author><name>Amar Spahic</name></author><category term="react-native" /><category term="mobile" /><category term="ios" /><category term="android" /><category term="ux" /><category term="notifications" /><category term="frontend-mobile" /><category term="case-study" /><summary type="html"><![CDATA[I work on a B2C health app where notifications aren't a growth channel, they're half the product. The user sets a sleep goal, the app reminds them to wind down before bedtime. They pick a protocol with a daily action, the app nudges them to do it. Without notification permission all of that just doesn't exist for the user, they set goals in onboarding and then nothing ever follows up on them.]]></summary></entry><entry><title type="html">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/" rel="alternate" type="text/html" title="From DevOps to DevSecOps: How I Became Clearview&apos;s In-House Pen-Tester" /><published>2026-07-11T11:00:00+02:00</published><updated>2026-07-11T11:00:00+02:00</updated><id>https://blog.clearview.team/2026/from-devops-to-devsecops-why-i-started-breaking-things</id><content type="html" xml:base="https://blog.clearview.team/2026/from-devops-to-devsecops-why-i-started-breaking-things/"><![CDATA[<p>It took me a decade of shipping things reliably before I started spending my nights trying to break them.</p>

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

<p>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.</p>

<h2 id="the-night-the-job-description-changed">The Night the Job Description Changed</h2>

<p>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.</p>

<p><em>"That could never happen to us,"</em> I thought. <em>"We have policies. We check these things."</em> Then I got up, opened my laptop, and started checking anyway.</p>

<p>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.</p>

<p>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 <code class="language-plaintext highlighter-rouge">checkov</code> rule ran in the pipeline. Nobody had ever gone back. They had <code class="language-plaintext highlighter-rouge">terraform apply</code>-ed once and moved on.</p>

<p>Those buckets were not somebody else'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.</p>

<p>I fixed them that morning, wrote up the incident, told the team. And then I could not stop.</p>

<h2 id="what-an-engineer-reads-at-night-when-they-cannot-sleep">What an Engineer Reads at Night When They Cannot Sleep</h2>

<p>I started spending evenings on a different reading list:</p>

<ul>
  <li><strong>JWT vulnerabilities</strong> — specifically how stateless tokens make revocation almost impossible</li>
  <li><strong>SQL injection patterns that still work in 2026</strong> — parameterised queries handle the textbook case, but ORMs like TypeORM ship their own foot-guns (raw queries, <code class="language-plaintext highlighter-rouge">addSelect</code> on relations, <code class="language-plaintext highlighter-rouge">QueryBuilder</code> with string interpolation)</li>
  <li><strong>CORS bypasses</strong> — wildcard subdomain matching, reflected origins, preflight caching abuse</li>
  <li><strong>What <code class="language-plaintext highlighter-rouge">trust proxy</code> actually does in Express</strong> — 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</li>
</ul>

<p>I am not claiming to be a research-level expert in any of these. I am claiming <em>curiosity</em>. 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.</p>

<p>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.</p>

<p>I was running <code class="language-plaintext highlighter-rouge">curl</code> 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.</p>

<p>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 <code class="language-plaintext highlighter-rouge">aws s3 cp --dryrun</code>, which validates syntax but not IAM. The actual upload failed with <code class="language-plaintext highlighter-rouge">AccessDenied</code>. I had to correct my own report the next morning.</p>

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

<h2 id="what-tools-catch-and-what-they-miss">What Tools Catch and What They Miss</h2>

<p>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.</p>

<p>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 <code class="language-plaintext highlighter-rouge">package-lock.json</code>. These are table stakes — you should run them no matter who is on your team.</p>

<p>But automated tools find <em>known patterns</em>. What I find by hand is different:</p>

<ul>
  <li>The CORS rule that accepts any subdomain — technically valid regex, but allows attacker-controlled origins</li>
  <li>The scientific-notation invoice for one million dollars — <code class="language-plaintext highlighter-rouge">1e6</code> passes <code class="language-plaintext highlighter-rouge">IsNumber()</code> validation because it <em>is</em> a number, but the business logic never expected six figures</li>
  <li>The internal fields that nobody should be allowed to set, but anyone can — <code class="language-plaintext highlighter-rouge">Object.assign(user, req.body)</code> happily merges <code class="language-plaintext highlighter-rouge">isDeleted</code> and <code class="language-plaintext highlighter-rouge">role</code> along with everything else</li>
  <li>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</li>
</ul>

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

<p>That said, manual testing does not scale. I cannot personally <code class="language-plaintext highlighter-rouge">curl</code> 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.</p>

<h2 id="the-role-we-wrote-around-it">The Role We Wrote Around It</h2>

<p>At some point my late-night reading turned into part of the job. It happened gradually.</p>

<p>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.</p>

<p>Then I started doing quiet pre-deployment reviews. Not formal audits. Just <em>"let me look at this endpoint before it goes live."</em></p>

<p>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 <code class="language-plaintext highlighter-rouge">curl -X POST</code> and a JSON payload, every minute, forever.</p>

<p>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 <em>exactly</em> 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.</p>

<p>After that, the role was real.</p>

<p>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, <em>how do I break this?</em></p>

<p><img src="/assets/images/posts/from-devops-to-devsecops-why-i-started-breaking-things/sprint-flow.svg" alt="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." /></p>

<p>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.</p>

<h2 id="what-i-catch-on-the-pen-tester-pass">What I Catch on the Pen-Tester Pass</h2>

<p>Here is the shortlist of categories that show up over and over again across stacks and clients:</p>

<p><img src="/assets/images/posts/from-devops-to-devsecops-why-i-started-breaking-things/six-categories.svg" alt="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." /></p>

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

<p>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.</p>

<h2 id="what-it-gets-the-client">What It Gets the Client</h2>

<p>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.</p>

<p>What the client gets:</p>

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

<p>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, <em>no Clearview client has shipped a production-incident-class security bug from a feature that went through the pen-tester pass</em>. 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.</p>

<h2 id="what-this-costs-us">What This Costs Us</h2>

<p>Adding this role to every engagement is not free.</p>

<ul>
  <li><strong>A sprint takes a little longer.</strong> Time-boxed, but real.</li>
  <li><strong>Some features get re-scoped.</strong> 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.</li>
  <li><strong>The pen tester needs a peer.</strong> 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.</li>
</ul>

<p>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.</p>

<h2 id="the-mindset-shift-for-anyone-considering-the-move">The Mindset Shift, for Anyone Considering the Move</h2>

<p>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 <em>what if?</em> about the systems you already own.</p>

<p>Five things that helped me, in order:</p>

<ol>
  <li><strong>Audit your own infrastructure first.</strong> Run <code class="language-plaintext highlighter-rouge">checkov</code> on your Terraform. Check your S3 bucket policies. Review your IAM roles. You will probably find something, and it will motivate everything that follows.</li>
  <li><strong>Learn one attack vector deeply.</strong> 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.</li>
  <li><strong>Read the detailed breach post-mortems.</strong> Not the sensational ones. Cloudflare's. GitLab's. The ones that explain the root cause, the timeline, and what changed. That is where real attacks live.</li>
  <li><strong>Break something — in staging, with permission.</strong> 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.</li>
  <li><strong>Turn every finding into a check.</strong> Every manual finding should become a rule that runs automatically next time. Found a public S3 bucket? Add a <code class="language-plaintext highlighter-rouge">checkov</code> 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 <em>unnecessary for the easy stuff</em>, so you can spend your time on the hard stuff.</li>
</ol>

<p>The paranoia helps, but the checklists help more.</p>

<aside class="callout">
  <p><strong>A quiet rule, but a firm one.</strong> Only test against systems you own or have explicit written authorisation to test. <em>"Staging"</em> does not mean <em>"someone else's staging."</em> 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.</p>
</aside>

<aside class="post-cta">
  <h2 id="we-could-put-a-pen-tester-pass-on-your-sprint">We Could Put a Pen-Tester Pass on Your Sprint</h2>

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

  <p>This is the kind of engagement <strong>Clearview Team</strong> 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.</p>

  <p><a href="mailto:info@clearview.team?subject=Pen-tester%20pass%20engagement%20enquiry">Scope an engagement →</a></p>
</aside>]]></content><author><name>Nedim Hadzimahmutovic</name></author><category term="devsecops" /><category term="security" /><category term="application-security" /><category term="pen-testing" /><category term="aws" /><category term="terraform" /><category term="mindset" /><summary type="html"><![CDATA[It took me a decade of shipping things reliably before I started spending my nights trying to break them.]]></summary></entry><entry><title type="html">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/" rel="alternate" type="text/html" title="An Event-Driven Architecture Case Study: Lessons from a Real-World Application" /><published>2025-04-07T19:32:53+02:00</published><updated>2025-04-07T19:32:53+02:00</updated><id>https://blog.clearview.team/2025/an-event-driven-architecture-case-study-lessons-from-a-real-world-application</id><content type="html" xml:base="https://blog.clearview.team/2025/an-event-driven-architecture-case-study-lessons-from-a-real-world-application/"><![CDATA[<p>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.</p>

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

<h4 id="the-client">The Client</h4>

<p>A Confidential Non-profit and Awards Group
*Membership Operations, Automated and Simplified *</p>

<p>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.</p>

<p><strong>More than a decade later</strong>, 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.</p>

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

<h4 id="the-digital-workflow-platform">The Digital Workflow Platform</h4>

<p><em>Annual Intake and Review Workflow</em></p>

<p>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.</p>

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

<ul>
  <li><strong>Fully web-based intake process</strong>, including secure online payment; upload of video clips, documents, and signoff forms; followup intake for supplementary materials.</li>
  <li><strong>Automated media verification, storage</strong>, and transcoding, utilizing Vimeo API integration for high-quality, secure, and familiar playback for reviewers. All media is secured against unauthorized download.</li>
  <li><strong>Advanced partner dashboard</strong>, enabling organizations with many moving parts to delegate intake tasks to multiple designated contributors and divide labor.</li>
  <li><strong>Review management</strong>, 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.</li>
  <li><strong>Selection management</strong>, including application and eligibility processes and a secure selection interface.</li>
  <li><strong>Tight integration</strong> with the organization’s membership database for permissions verification.</li>
  <li><strong>Extensive administration dashboard</strong>, 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.</li>
  <li><strong>Secure, triple-redundant data archival</strong> following each year’s cycle.</li>
  <li><strong>On-call support</strong>, including email support for contributors, reviewers, and participants.</li>
</ul>

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

<h4 id="challenges">Challenges</h4>

<p>The challenges we aim to solve with this approach:</p>

<ul>
  <li><strong>Decoupling of services:</strong> To improve the agility of the application we designed our system not to be tightly coupled.</li>
  <li><strong>Real-time responsiveness:</strong> 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.</li>
  <li><strong>Improved observability:</strong> 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.</li>
</ul>

<h4 id="deployment">Deployment</h4>

<p>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.</p>

<p><em>Event Types</em>
We have covered the most important event types in the app, such as:</p>

<ul>
  <li>Finance events</li>
  <li>Membership events</li>
  <li>Scheduled job events</li>
  <li>Health check events.</li>
</ul>

<h4 id="goals">Goals</h4>

<p>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.</p>

<p><img src="/assets/images/posts/an-event-driven-architecture-case-study-lessons-from-a-real-world-application/cover.svg" alt="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." /></p>

<h3 id="system-architecture">System Architecture</h3>
<p>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.</p>

<h4 id="event-driven-architecture">Event-driven Architecture</h4>
<p>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.</p>

<blockquote>
  <p>Event-driven architecture is often called <strong>EDA</strong> for short.</p>
</blockquote>

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

<h4 id="event-driven-architecture-showcase">Event-driven Architecture Showcase</h4>

<p>Below is a diagram showing what a typical <strong>EDA</strong> system looks like and how it functions.</p>

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

<h3 id="the-application">The Application</h3>
<p>The application is built on <strong>NodeJS</strong> 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.</p>

<figure class="post-figure">
  <img src="/assets/images/posts/an-event-driven-architecture-case-study-lessons-from-a-real-world-application/1_2noPlSZ42q4dacE1fg34HQ.png" width="1024" height="298" alt="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." loading="lazy" decoding="async" />
  <figcaption>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.</figcaption>
</figure>

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

<h3 id="event-producers">Event Producers</h3>
<p>Events are generated from various services and can be produced either by a client action or by a scheduled job.</p>

<h4 id="api-event-producer">API Event Producer</h4>

<p>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.</p>

<h4 id="api-events-list">API Events List</h4>

<p>The core services of the app produce events. Below is a list of services with a description of what each does.</p>

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

<h4 id="api-produced-sample-events">API Produced Sample Events</h4>

<p><em>Finance Related Events</em>
Below you can find payment processing failed sample events</p>

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

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

<p><img src="/assets/images/posts/an-event-driven-architecture-case-study-lessons-from-a-real-world-application/1_s8Nk7t3gSSio5cGshNekXQ.png" alt="Event-driven architecture diagram" /></p>

<h4 id="scheduler-event-producer">Scheduler Event Producer</h4>

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

<blockquote>
  <p>In this case, the Event Producer is the Scheduler itself.</p>
</blockquote>

<figure class="post-figure">
  <img src="/assets/images/posts/an-event-driven-architecture-case-study-lessons-from-a-real-world-application/1_ToHAJzE0Jw55CmjYVPy86w.png" width="1024" height="934" alt="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." loading="lazy" decoding="async" />
  <figcaption>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.</figcaption>
</figure>

<p>As per the diagram every cron job has the following flow:</p>

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

<h4 id="evaluated-jobs">Evaluated Jobs</h4>

<p>The complete list can be found below.</p>

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

<h4 id="company-statistic-scheduled-job">Company Statistic Scheduled Job</h4>

<figure class="post-figure">
  <img src="/assets/images/posts/an-event-driven-architecture-case-study-lessons-from-a-real-world-application/1_HWvN5-hrVkBoB_dMloEovQ.png" width="1024" height="716" alt="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." loading="lazy" decoding="async" />
  <figcaption>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.</figcaption>
</figure>

<p>Event Scheduler Job Notes:</p>

<ul>
  <li>Recurring schedule type</li>
  <li>Cron expression style</li>
  <li>Runs every 8 hours</li>
  <li>Target: Scheduler Bus</li>
  <li>API PutEvents</li>
</ul>

<h4 id="event-rule">Event Rule</h4>

<p>The bus receives the event and triggers the api cron company statistic rule.</p>

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

<p><img src="/assets/images/posts/an-event-driven-architecture-case-study-lessons-from-a-real-world-application/1_v4C4WRHUZntsaZPQjN2G9w.png" alt="Event-driven architecture diagram" /></p>

<h3 id="the-infrastructure">The Infrastructure</h3>
<p>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.</p>

<p><strong><em>The AWS Account</em></strong></p>

<p>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.</p>

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

<p>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.</p>

<h4 id="aws-rds">AWS RDS</h4>

<p>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.</p>

<p>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.</p>

<figure class="post-figure">
  <img src="/assets/images/posts/an-event-driven-architecture-case-study-lessons-from-a-real-world-application/1_OiFXhQsnZMFppyx3zlBL6Q.png" width="1024" height="759" alt="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." loading="lazy" decoding="async" />
  <figcaption>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.</figcaption>
</figure>

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

<h4 id="the-eventbridge-rule">The EventBridge Rule</h4>

<p><img src="/assets/images/posts/an-event-driven-architecture-case-study-lessons-from-a-real-world-application/1_uLTdeDSNNGkcKN1UOCqy0g.png" alt="Event-driven architecture diagram" /></p>

<h4 id="aws-health">AWS Health</h4>

<figure class="post-figure">
  <img src="/assets/images/posts/an-event-driven-architecture-case-study-lessons-from-a-real-world-application/1_609A4DS0lb9LF2DCiAmZdg.png" width="1024" height="685" alt="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." loading="lazy" decoding="async" />
  <figcaption>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.</figcaption>
</figure>

<p>Events type can be one of the following categories:</p>

<ul>
  <li>accountNotification</li>
  <li>issue</li>
  <li>scheduledChange</li>
</ul>

<p>AWS Health events are sent to the <strong>default</strong> 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.</p>

<h4 id="ec2-service">EC2 service</h4>

<p><img src="/assets/images/posts/an-event-driven-architecture-case-study-lessons-from-a-real-world-application/1_mkqrarvjHW-AOxG7ZA7XZQ.png" alt="Event-driven architecture diagram" /></p>

<h4 id="sample-rules">Sample rules</h4>

<p><em>Trigger on Every Event</em></p>

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

<p>Rule for a specific service and event type category.</p>

<p>In this example, we will create a rule so that EventBridge reacts to the following.</p>

<p><img src="/assets/images/posts/an-event-driven-architecture-case-study-lessons-from-a-real-world-application/1_QtFrPLM5IxieQQHNR99hUQ.png" alt="Event-driven architecture diagram" /></p>

<h4 id="multiple-services-and-event-type-categories">Multiple Services and Event Type Categories</h4>

<p>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</p>

<h4 id="example-rule">Example Rule</h4>

<p><img src="/assets/images/posts/an-event-driven-architecture-case-study-lessons-from-a-real-world-application/1_3WDUut-SEV5rkMaP2QIw-A.png" alt="Event-driven architecture diagram" /></p>

<p><img src="/assets/images/posts/an-event-driven-architecture-case-study-lessons-from-a-real-world-application/1_3ZKR4sD7DNN-ZyIejrP2ng.png" alt="Event-driven architecture diagram" /></p>

<aside class="post-cta">
  <h2 id="we-could-architect-your-event-flow">We Could Architect Your Event Flow</h2>

  <p>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 <strong>Clearview Team</strong> 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.</p>

  <p><a href="/work-with-us/">Scope an engagement →</a></p>
</aside>]]></content><author><name>Nedim Hadzimahmutovic</name></author><category term="application-development" /><category term="event-driven-architecture" /><category term="case-study" /><category term="system-architecture" /><category term="aws-devops" /><summary type="html"><![CDATA[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.]]></summary></entry><entry><title type="html">From Localhost To Production — Best Practice on Software Development and Deployment</title><link href="https://blog.clearview.team/2024/from-localhost-to-production-best-practice-on-software-development-and-deployment/" rel="alternate" type="text/html" title="From Localhost To Production — Best Practice on Software Development and Deployment" /><published>2024-05-18T11:02:12+02:00</published><updated>2024-05-18T11:02:12+02:00</updated><id>https://blog.clearview.team/2024/from-localhost-to-production-best-practice-on-software-development-and-deployment</id><content type="html" xml:base="https://blog.clearview.team/2024/from-localhost-to-production-best-practice-on-software-development-and-deployment/"><![CDATA[<h3 id="from-localhost-to-productionbest-practice-on-software-development-and-deployment">From Localhost To Production — Best Practice on Software Development and Deployment</h3>

<figure class="post-figure">
  <img src="/assets/images/posts/from-localhost-to-production-best-practice-on-software-development-and-deployment/1_t39ONHV42Jjmpxhdc4-vjw.jpeg" width="1024" height="1024" alt="Photo by NASA on Unsplash" loading="lazy" decoding="async" />
  <figcaption>Photo by <a href="https://unsplash.com/@nasa?utm_content=creditCopyText&amp;utm_medium=referral&amp;utm_source=unsplash">NASA</a> on <a href="https://unsplash.com/photos/astronaut-in-spacesuit-floating-in-space-Yj1M5riCKk4?utm_content=creditCopyText&amp;utm_medium=referral&amp;utm_source=unsplash">Unsplash</a></figcaption>
</figure>

<p>I remember the joy when I first deployed my web application using cPanel FTP. I right clicked on my folder | compress to zip, then upload it to cPanel, uncompress everything, and setup the credentials — no .env file, everything is hardcoded on the config file.</p>

<p>It took me multiple years of learning and experience to be able to deploy a software correctly. I will write a summary on how to do it, one for my personal reference, and two so people can learn about it as well and doesn’t have to fall into the pit of having their software hacked.</p>

<h3 id="assumption">Assumption</h3>

<p>This article assume that we will be deploying these software systems:</p>

<ul>
  <li>Server (VM)</li>
  <li>Database</li>
  <li>Backend API</li>
  <li>Frontend</li>
  <li>Nginx</li>
  <li>SSL</li>
</ul>

<p>We won’t go deep into horizontal scaling or multiservice architecture as most of the time, you won’t need it.</p>

<p>We assumed that you have experience on interacting with server, writing code, and using Linux.</p>

<h3 id="setting-up-the-server">Setting Up The Server</h3>

<p>We will go with the traditional route of using a virtual machine / virtual private server — instead of ready to use system like AWS AppRunner or Google App Engine.</p>

<p>You will have to pick an OS — most providers support Windows, but unless you are deploying Microsoft based software like .NET — it’s always a good idea to use Linux.</p>

<p>Go ahead and set up your server using Digital Ocean, Google Compute Engine, or any other providers.</p>

<h4 id="securing-the-server">Securing The Server</h4>

<p>The first step that you need to do once you got your server up and running is to secure it.</p>

<p>The main gate of your server is most likely an SSH server, so we will secure it first.</p>

<ol>
  <li>Create an SSH key if you don’t have already</li>
  <li>Create a new user with strong password, use your SSH key for this user</li>
  <li>Add the user to sudoers</li>
  <li>Configure SSH to only allow this user to log in</li>
  <li>Configure SSH to prevent password login</li>
  <li>Configure SSH to disallow root login</li>
</ol>

<blockquote>
  <p>The &gt; means you write it as content of the file, not an actual character that you type.</p>
</blockquote>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Run on your local</span>
ssh-keygen <span class="nt">-t</span> rsa <span class="nt">-b</span> 4096

<span class="c"># Connect to your server</span>
adduser new_username
<span class="nb">mkdir</span> /home/new_username/.ssh
<span class="nb">chmod </span>700 /home/new_username/.ssh
nano /home/new_username/.ssh/authorized_keys

usermod <span class="nt">-aG</span> <span class="nb">sudo </span>new_username

nano /etc/ssh/sshd_config
<span class="o">&gt;</span> AllowUsers new_username
<span class="o">&gt;</span> PasswordAuthentication no
<span class="o">&gt;</span> PermitRootLogin no
</code></pre></div></div>

<p>Then we need to configure the firewall to shutdown everything.</p>

<ol>
  <li>Enable firewall</li>
  <li>Disallow all port except SSH</li>
</ol>

<p> </p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code># firewall
ufw enable
ufw default deny incoming
ufw allow ssh
</code></pre></div></div>

<p>We will whitelist our port later.</p>

<p>Next, use the OS package manager (apt, yum, etc) to update all existing softwares.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>apt update
apt upgrade
</code></pre></div></div>

<h3 id="setting-up-database">Setting Up Database</h3>

<p>For our database server, we’ll be using PostgreSQL, a battle-tested and feature-rich open-source database management system. MySQL is a good alternative but I kept forgetting how to setup Postgres properly so here I am writing about it.</p>

<p>Installing PostgreSQL on our Ubuntu server is a straightforward process. First, we’ll update the package index and install the PostgreSQL package:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>apt update
<span class="nb">sudo </span>apt <span class="nb">install </span>postgresql
</code></pre></div></div>

<p>After the installation, PostgreSQL automatically creates a default database cluster. However, we’ll create a new cluster with our preferred settings to ensure optimal performance and configuration.</p>

<p>Switch to the PostgreSQL user and initialize a new cluster with the desired locale and encoding settings:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>su - postgres
initdb <span class="nt">-D</span> /path/to/data/directory <span class="nt">--locale</span><span class="o">=</span>en_US.UTF-8 <span class="nt">--encoding</span><span class="o">=</span>UTF8
</code></pre></div></div>

<p>Next, we’ll configure PostgreSQL by editing the postgresql.conf file located in the data directory we specified during cluster initialization. Here, we can adjust settings such as listen addresses, port numbers, maximum connections, shared buffers, and memory allocation for various operations.</p>

<p>For authentication, we’ll edit the pg_hba.conf file. During development, we can use peer or ident authentication for local connections and MD5 for remote connections. However, in a production environment, it's recommended to use MD5 or certificate-based authentication for enhanced security.</p>

<p>To create a dedicated PostgreSQL user and database for our application, we’ll execute the following commands as the postgres user:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>createuser --pwprompt app_user
createdb --owner=app_user app_database
</code></pre></div></div>

<p>This creates a new user (app_user) and a dedicated database (app_database) owned by that user.</p>

<p>By default, PostgreSQL doesn’t allow remote connections. To enable remote access, we’ll update the listen_addresses parameter in postgresql.conf and add a line in pg_hba.conf to allow remote connections with appropriate authentication methods.</p>

<p>Securing the PostgreSQL server matters. We’ll disable the PostgreSQL user’s ability to log in via password, use strong passwords for database users, consider SSL/TLS encryption for connections, and regularly update PostgreSQL to the latest version for security patches.</p>

<p>Regular backups are essential for data integrity and disaster recovery. We’ll set up backup procedures using tools like pg_dump or pg_basebackup, storing backups in a secure off-site or cloud location. Testing backup and restore processes regularly is also a best practice.</p>

<p>Performance monitoring and tuning are ongoing tasks. We’ll use tools like pgBadger or pg_stat_statements to monitor PostgreSQL's performance, tune database settings based on workload and hardware resources, implement indexing strategies, and consider partitioning large tables for better management and performance.</p>

<p>With our PostgreSQL database server set up, configured, and secured according to best practices, we’re ready to connect our application and begin development and deployment processes.</p>

<blockquote>
  <p>Setting up your server timezone so your server, dabase, and application uses the same timezone settings will save you headache in the future. Use UTC if you are serving international customers, or set it to your local time if you are sure that it’s only going to be used internally or specific to your region.</p>
</blockquote>

<h3 id="preparing-your-backend-service">Preparing Your Backend Service</h3>

<p>The first step that you need to do is to ensure that you don’t have any secret credentials in any of your version controller file. Use environment variables and make sure your backend service use the environment value instead of hard coding it on your code.</p>

<p>We won’t talk about how you version control or how you managed to get your code into the server. A quick info on this, you can create an SSH key on your server and use it for deploy keys on GitHub.</p>

<p>Once you securely move all of your secret information into environment variables, it’s time to get it up and running</p>

<h4 id="the-daemon">The Daemon</h4>

<p>While you can just npm start — it will run in foreground and once your session ends, your backend service will die as well.</p>

<p>Systemd is a popular daemon system that we can use to ensure our backend service will keep running even when we close our SSH session, or when it fails and need to restart.</p>

<p>First, we need to have a dedicated user to run our service. So create a new user and define a daemon configuration that uses this user.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>adduser backend-app-user

nano /etc/systemd/system/backend-app.service

<span class="o">&gt;</span> <span class="o">[</span>Unit]
<span class="o">&gt;</span> <span class="nv">Description</span><span class="o">=</span>Backend Application
<span class="o">&gt;</span> <span class="nv">After</span><span class="o">=</span>network.target

<span class="o">&gt;</span> <span class="o">[</span>Service]
<span class="o">&gt;</span> <span class="nv">User</span><span class="o">=</span>backend-app-user
<span class="o">&gt;</span> <span class="nv">Group</span><span class="o">=</span>backend-app-user
<span class="o">&gt;</span> <span class="nv">WorkingDirectory</span><span class="o">=</span>/path/to/backend/app
<span class="o">&gt;</span> <span class="nv">Environment</span><span class="o">=</span><span class="nv">NODE_ENV</span><span class="o">=</span>production
<span class="o">&gt;</span> <span class="nv">Environment</span><span class="o">=</span><span class="nv">PORT</span><span class="o">=</span>3000
<span class="o">&gt;</span> <span class="nv">ExecStart</span><span class="o">=</span>/usr/bin/node /path/to/backend/app/app.js
<span class="o">&gt;</span> <span class="nv">Restart</span><span class="o">=</span>always
<span class="o">&gt;</span> <span class="nv">RestartSec</span><span class="o">=</span>10

<span class="o">&gt;</span> <span class="o">[</span>Install]
<span class="o">&gt;</span> <span class="nv">WantedBy</span><span class="o">=</span>multi-user.target

<span class="nb">sudo </span>systemctl daemon-reload
<span class="nb">sudo </span>systemctl start backend-app
<span class="nb">sudo </span>systemctl status backend-app
<span class="nb">sudo </span>systemctl <span class="nb">enable </span>backend-app
</code></pre></div></div>

<p>Now your backend service is up and running, it’s time to set up the frontend.</p>

<h3 id="deploying-the-frontend">Deploying The Frontend</h3>

<p>Most frontend is just a static files, even if you use framework like React, Vue, Angular — in the end it will be compiled into a static file.</p>

<p>We won’t talk about server-side frontend like Next, you’d better of writing a fullstack application using Rails or Laravel — trust me.</p>

<p>Because front end is just static files, we will just need to make sure that we can bring our frontend artifacts into our server.</p>

<h3 id="nginx">Nginx</h3>

<p>Nginx is fast, small, and easy to configure. So let’s use it.</p>

<p>First, let’s install Nginx on our server:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>apt update
<span class="nb">sudo </span>apt <span class="nb">install </span>nginx
</code></pre></div></div>

<p>After the installation, Nginx will start automatically, and you can verify its status with the following command:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>sudo systemctl status nginx
</code></pre></div></div>

<p>Next, we’ll configure Nginx to serve our frontend application’s static files. Create a new configuration file (e.g., frontend.conf) in the /etc/nginx/conf.d/ directory:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>sudo nano /etc/nginx/conf.d/frontend.conf
</code></pre></div></div>

<p>Paste the following configuration into the file, replacing /path/to/frontend/dist with the actual path to your frontend application's built or compiled static files:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>server {
    listen 80;
    server_name your_domain.com www.your_domain.com;
    root /path/to/frontend/dist;
    index index.html;
    location / {
        try_files $uri $uri/ /index.html;
    }
}
</code></pre></div></div>

<p>This configuration tells Nginx to listen on port 80 (the default HTTP port) for requests to your_domain.com and www.your_domain.com. It sets the document root to /path/to/frontend/dist, which is where your frontend application's static files are located.</p>

<p>The try_files directive ensures that Nginx will first try to serve the requested file or directory. If neither exists, it will serve the index.html file, enabling client-side routing for single-page applications.</p>

<h4 id="but-how-do-i-call-backend-from-my-frontend">But How Do I Call Backend From My Frontend?</h4>

<p>We managed to run our backend service, but it running locally on a local port. We want to have a reverse-proxy that act as a gate. So it will become like FE -&gt; Nginx Proxy -&gt; Backend.</p>

<p>Modify our nginx website conf,</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>server {
    listen 80;
    server_name your_domain.com www.your_domain.com;

    root /path/to/frontend/dist;
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;
    }

    location /api/ {
        proxy_pass http://localhost:3000/;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }
}
</code></pre></div></div>

<p>Now your_domain.com/api become the base endpoint for your API.</p>

<h4 id="securing-nginx-with-ssltls">Securing Nginx with SSL/TLS</h4>

<p>It’s 2024, if you try to open <code class="language-plaintext highlighter-rouge">your_domain.com</code> — browser will shame you publicly by saying your website is dangerous/insecure/badly written in React.</p>

<blockquote>
  <p>Before we can obtain our SSL certificate, we need a domain name that points to our server. You can open your domain provider and setup an A record that points to your server IP.</p>
</blockquote>

<p>Being 2024 means we have Let’s Encrypt to help us obtain SSL certificate. Let’s start with installing certbot.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>apt update
<span class="nb">sudo </span>apt <span class="nb">install </span>certbot python3-certbot-nginx
<span class="nb">sudo </span>certbot <span class="nt">--nginx</span>
</code></pre></div></div>

<p>Certbot will automatically configure everything for you. Make sure that everything is good, test your nginx configuration and reload it.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>nginx <span class="nt">-t</span>
<span class="nb">sudo </span>systemctl reload nginx
</code></pre></div></div>

<p>Let’s Encrypt SSL has a short lifetime, make sure to run sudo certbot renew every 90 days to make sure that your certificate is valid. Most of the time this will be done automatically.</p>

<p>Now, enable port 80 and 443 on your firewall and you’re all set. You probably want to do more future proofing like setting backups, setting monitoring, compressions, caching, etc. But, enjoy your first step for now 🎉</p>

<aside class="post-cta">
  <h2 id="we-could-take-this-the-rest-of-the-way">We Could Take This The Rest of the Way</h2>

  <p>You shipped the first deploy — well done. The second mile is where it gets unglamorous: backups that actually restore, monitoring that pages the right person at the right hour, cache headers and compression that survive a traffic spike, log rotation, automatic certificate renewal, the secrets-management piece you swore you would come back to. That second mile is what <strong>Clearview Team</strong> does for a living.</p>

  <p>If your stack is roughly the one in this post — Node, Postgres, Nginx, a single Ubuntu box you would like to keep running quietly — write to us and we will take a look.</p>

  <p><a href="mailto:info@clearview.team?subject=Production%20hardening%20enquiry">Brief us on your stack →</a></p>
</aside>]]></content><author><name>Aditya Purwa</name></author><category term="software-engineering" /><category term="sysops" /><category term="devops" /><category term="software" /><category term="deployment" /><summary type="html"><![CDATA[From Localhost To Production — Best Practice on Software Development and Deployment]]></summary></entry></feed>