<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ Security - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ Security - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Sun, 04 Oct 2026 00:07:18 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/security/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How ORMs Still Let SQL Injection Through (and How to Close the Gaps) ]]>
                </title>
                <description>
                    <![CDATA[ A lot of developers assume that once they're on an ORM, SQL injection stops being their problem. But it doesn't disappear. It just relocates. ORMs like Sequelize, Prisma, TypeORM, and Knex parameteriz ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-orms-still-let-sql-injection-through-and-how-to-close-the-gaps/</link>
                <guid isPermaLink="false">6abb5c6f814bf2c63668f9fc</guid>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ SQL ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ node ]]>
                    </category>
                
                    <category>
                        <![CDATA[ orm ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Hackita ]]>
                </dc:creator>
                <pubDate>Tue, 29 Sep 2026 06:36:31 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/9e1f15e5-de00-4533-8349-70620154f7ef.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>A lot of developers assume that once they're on an ORM, SQL injection stops being their problem. But it doesn't disappear. It just relocates.</p>
<p>ORMs like Sequelize, Prisma, TypeORM, and Knex parameterize the queries they build for you automatically. That part works.</p>
<p>The problem shows up in the parts of your app where you step outside that safe path: a raw query the ORM's query builder can't express cleanly, a dynamic sort column, or a single helper function reached for without a second thought.</p>
<p>Those are exactly the places <a href="https://hackita.it/articoli/hacker/">attackers go looking first</a>, and they're easy for developers to miss precisely because the rest of the codebase feels protected.</p>
<p>Four of those patterns are the focus here. Each one gets a working example that triggers the bug, a breakdown of why it's exploitable, and a rewritten version that closes it. I also include notes on exactly what moved and why.</p>
<p>You don't need a security background to follow along. You just need enough comfort with Node.js, SQL, and at least one ORM to read the code.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>The examples assume you're comfortable with:</p>
<ul>
<li><p>The basics of Node.js and Express</p>
</li>
<li><p>Writing basic SQL queries</p>
</li>
<li><p>Basic use of an ORM (all code examples use Sequelize, but the patterns apply to Prisma, TypeORM, Knex, and others too)</p>
</li>
<li><p>How an HTTP request/response cycle works</p>
</li>
</ul>
<p><strong>Note on code examples</strong>: Throughout this guide, <code>sequelize</code> is assumed to already be an initialized Sequelize instance, with <code>QueryTypes</code> and <code>Op</code> imported from <code>'sequelize'</code> alongside it, and <code>User</code>/<code>Product</code> are Sequelize models defined elsewhere in the app. Examples use Sequelize v6+ syntax (the current major version) since some of the APIs referenced here changed between major versions. You can adapt the syntax to the version and ORM you use.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-1-raw-query-escape-hatches">1. Raw Query Escape Hatches</a></p>
</li>
<li><p><a href="#heading-2-identifiers-that-cant-be-parameterized">2. Identifiers That Can't Be Parameterized</a></p>
</li>
<li><p><a href="#heading-3-second-order-injection-through-stored-data">3. Second-Order Injection Through Stored Data</a></p>
</li>
<li><p><a href="#heading-4-raw-sql-smuggled-into-normal-orm-calls">4. Raw SQL Smuggled Into Normal ORM Calls</a></p>
</li>
<li><p><a href="#heading-summary">Summary</a></p>
</li>
</ul>
<h2 id="heading-1-raw-query-escape-hatches">1. Raw Query Escape Hatches</h2>
<p>Every major ORM ships an "escape hatch" for queries the query builder can't express cleanly: <code>sequelize.query()</code>, Prisma's <code>$queryRawUnsafe</code>, or TypeORM's <code>query()</code>. They exist for good reasons, like complex joins, window functions, and vendor-specific SQL.</p>
<p>The trouble starts when developers treat that escape hatch like the rest of the ORM, and interpolate user input directly into the string it builds.</p>
<h3 id="heading-how-to-identify-this-vulnerability-in-your-code">How to Identify This Vulnerability in Your Code</h3>
<p>Here's a typical filtered report endpoint:</p>
<pre><code class="language-javascript">// Express.js - Vulnerable
app.get('/reports', async (req, res) =&gt; {
  const { region } = req.query;
  const results = await sequelize.query(
    `SELECT * FROM sales WHERE region = '${region}'`,
    { type: QueryTypes.SELECT }
  );
  res.json(results);
});
</code></pre>
<p>The query builder never sees this string. It's handed straight to the database driver exactly as written.</p>
<h4 id="heading-why-this-matters">Why This Matters</h4>
<p>A request like <code>?region=' OR '1'='1</code> turns the query into <code>SELECT * FROM sales WHERE region = '' OR '1'='1'</code>, which is always true. Every row in the table comes back, regardless of region.</p>
<p>Worse, because this code lives inside an ORM-based project, it often doesn't get the same scrutiny a raw <code>mysql.query()</code> call would in a non-ORM codebase. Reviewers assume the ORM already handled it.</p>
<h3 id="heading-how-to-fix-this-vulnerability">How to Fix This Vulnerability</h3>
<p>Pass values through the replacement/binding mechanism the raw-query API already provides, instead of building the string yourself:</p>
<pre><code class="language-javascript">// Express.js - Secure
app.get('/reports', async (req, res) =&gt; {
  const { region } = req.query;
  const results = await sequelize.query(
    'SELECT * FROM sales WHERE region = :region',
    {
      replacements: { region },
      type: QueryTypes.SELECT
    }
  );
  res.json(results);
});
</code></pre>
<p>The fix isn't avoiding raw queries entirely, because sometimes you genuinely need them. It's never building the SQL string by hand when a binding mechanism is sitting right there.</p>
<p><strong>Never concatenate user input into a raw query string. Use the replacement/binding API your raw-query method already provides.</strong></p>
<h2 id="heading-2-identifiers-that-cant-be-parameterized">2. Identifiers That Can't Be Parameterized</h2>
<p>Parameterized queries protect <em>values</em>. They don't protect <em>identifiers</em> like table names, column names, or <code>ORDER BY</code> direction. Those have to be part of the SQL string itself.</p>
<p>That's why a dynamic sort feature is one of the most common places injection sneaks back into otherwise well-written ORM code.</p>
<h3 id="heading-how-to-identify-this-vulnerability-in-your-code">How to Identify This Vulnerability in Your Code</h3>
<p>Here's a typical sortable list endpoint:</p>
<pre><code class="language-javascript">// Express.js - Vulnerable
app.get('/users', async (req, res) =&gt; {
  const { sortBy = 'created_at' } = req.query;
  const users = await sequelize.query(
    `SELECT * FROM users ORDER BY ${sortBy}`,
    { type: QueryTypes.SELECT }
  );
  res.json(users);
});
</code></pre>
<p><code>sortBy</code> goes straight from the query string into the <code>ORDER BY</code> clause, with nothing in between.</p>
<h4 id="heading-why-this-matters">Why This Matters</h4>
<p><code>sortBy</code> looks like a harmless UI convenience until someone sends <code>created_at; DROP TABLE users; --</code>. Whether that exact payload runs depends on your database driver: Postgres's <code>pg</code> driver executes stacked statements like this by default, while MySQL's <code>mysql2</code> blocks them unless <code>multipleStatements: true</code> is explicitly set.</p>
<p>Either way, the underlying problem is the same: you have arbitrary SQL sitting in a position that was only ever meant to hold a column name.</p>
<h3 id="heading-how-to-fix-this-vulnerability">How to Fix This Vulnerability</h3>
<p>Since identifiers can't be bound as parameters, the only safe option is an allowlist:</p>
<pre><code class="language-javascript">// Express.js - Secure
const ALLOWED_SORT_COLUMNS = ['created_at', 'name', 'email'];

app.get('/users', async (req, res) =&gt; {
  const { sortBy = 'created_at' } = req.query;
  const column = ALLOWED_SORT_COLUMNS.includes(sortBy) ? sortBy : 'created_at';

  const users = await sequelize.query(
    `SELECT * FROM users ORDER BY ${column}`,
    { type: QueryTypes.SELECT }
  );
  res.json(users);
});
</code></pre>
<p>Never pass user input through to an identifier position, even after "sanitizing" it first. Sanitization for identifiers is much easier to get wrong than for values.</p>
<p><strong>Identifiers can't be parameterized. If user input decides a column or table name, allowlist it, don't sanitize it.</strong></p>
<h2 id="heading-3-second-order-injection-through-stored-data">3. Second-Order Injection Through Stored Data</h2>
<p>This one catches teams off guard because the input <em>was</em> parameterized...the first time it was written to the database.</p>
<p>The injection happens later, when that already-stored value gets reused inside a different, unparameterized query.</p>
<h3 id="heading-how-to-identify-this-vulnerability-in-your-code">How to Identify This Vulnerability in Your Code</h3>
<p>Here's a registration flow, and elsewhere in the codebase, an admin search feature:</p>
<pre><code class="language-javascript">// Express.js - Vulnerable

// Step 1: user registration, correctly parameterized
app.post('/register', async (req, res) =&gt; {
  await User.create({ username: req.body.username });
  res.json({ success: true });
});

// Step 2: somewhere else in the codebase, an admin search feature
app.get('/admin/search', async (req, res) =&gt; {
  const user = await User.findByPk(req.params.id);
  const results = await sequelize.query(
    `SELECT * FROM audit_log WHERE actor = '${user.username}'`,
    { type: QueryTypes.SELECT }
  );
  res.json(results);
});
</code></pre>
<p>Step 1 is completely safe on its own. Step 2 is where things go wrong.</p>
<h4 id="heading-why-this-matters">Why This Matters</h4>
<p>A username like <code>admin' OR '1'='1</code> passes through step 1 without any issue. Sequelize's <code>create()</code> parameterizes it, so it's stored exactly as typed. It sits in the database looking completely normal.</p>
<p>The payload only fires when step 2 pulls that value back out and drops it straight into a raw query string. "It's already in our own database" is not the same thing as "it's safe". It's still attacker-controlled if it originated from user input anywhere upstream.</p>
<h3 id="heading-how-to-fix-this-vulnerability">How to Fix This Vulnerability</h3>
<p>Same fix as pattern #1. You bind the value instead of concatenating it:</p>
<pre><code class="language-javascript">// Express.js - Secure

// Step 1 (registration) is unchanged — it was already parameterized

app.get('/admin/search', async (req, res) =&gt; {
  const user = await User.findByPk(req.params.id);
  const results = await sequelize.query(
    'SELECT * FROM audit_log WHERE actor = :actor',
    {
      replacements: { actor: user.username },
      type: QueryTypes.SELECT
    }
  );
  res.json(results);
});
</code></pre>
<p>The bug here is harder to spot than pattern #1, because the tainted data crosses a database round-trip before it becomes dangerous.</p>
<p><strong>Data from your own database isn't automatically safe. If it originated from user input anywhere upstream, it still needs to be parameterized everywhere it's used.</strong></p>
<h2 id="heading-4-raw-sql-smuggled-into-normal-orm-calls">4. Raw SQL Smuggled Into Normal ORM Calls</h2>
<p>Pattern #1 covered the <em>obvious</em> raw-query escape hatch, a method you reach for on purpose when you need real SQL. This one is sneakier: injection hiding inside a call that looks completely ORM-managed. It's not raw SQL, right? It's just one helper function among many.</p>
<h3 id="heading-how-to-identify-this-vulnerability-in-your-code">How to Identify This Vulnerability in Your Code</h3>
<p>Here's a typical filtered product listing:</p>
<pre><code class="language-javascript">// Express.js - Vulnerable
app.get('/products', async (req, res) =&gt; {
  const { minPrice } = req.query;
  const products = await Product.findAll({
    where: sequelize.literal(`price &gt; ${minPrice}`)
  });
  res.json(products);
});
</code></pre>
<p><code>Product.findAll()</code> looks like a fully safe, parameterized ORM call...right up until <code>sequelize.literal()</code> shows up inside it.</p>
<h4 id="heading-why-this-matters">Why This Matters</h4>
<p><code>literal()</code> tells Sequelize "don't touch this, insert it into the SQL exactly as written." Anything interpolated into it is exactly as injectable as pattern #1, just wearing a normal-looking ORM method as a disguise.</p>
<p>Send <code>?minPrice=0 OR 1=1</code> and the <code>WHERE</code> clause becomes unconditionally true: every row comes back, price filter or not. No stacked statement needed this time. It's a single boolean expression, so it works the same regardless of database driver.</p>
<h3 id="heading-how-to-fix-this-vulnerability">How to Fix This Vulnerability</h3>
<p>Stop reaching for <code>literal()</code>. Sequelize's own operator API already covers this case:</p>
<pre><code class="language-javascript">// Express.js - Secure
app.get('/products', async (req, res) =&gt; {
  const minPrice = Number(req.query.minPrice);

  if (!Number.isFinite(minPrice)) {
    return res.status(400).json({ error: 'minPrice must be a number' });
  }

  const products = await Product.findAll({
    where: { price: { [Op.gt]: minPrice } }
  });
  res.json(products);
});
</code></pre>
<p><code>Op.gt</code>, <code>Op.between</code>, <code>Op.in</code>, and the rest of Sequelize's operator API already cover the vast majority of cases people reach for <code>literal()</code> for, and they parameterize correctly by default. Validating that <code>minPrice</code> is actually a number, before it ever reaches the query, closes the gap even if a future refactor reintroduces <code>literal()</code> somewhere else.</p>
<p><strong>Grep for</strong> <code>literal(</code><strong>,</strong> <code>fn(</code><strong>, and your ORM's other raw escape hatches everywhere they appear in the codebase, not just inside the queries that are clearly "raw."</strong></p>
<h2 id="heading-summary">Summary</h2>
<p>For a quick recap, here's how the four patterns line up:</p>
<table>
<thead>
<tr>
<th>Pattern</th>
<th>Root Cause</th>
<th>Core Fix</th>
</tr>
</thead>
<tbody><tr>
<td>Raw Query Escape Hatches</td>
<td>User input concatenated into a raw query string</td>
<td>Bind values via the replacement/parameter API</td>
</tr>
<tr>
<td>Identifier Injection</td>
<td>Column/table/sort input can't be parameterized</td>
<td>Allowlist allowed identifiers explicitly</td>
</tr>
<tr>
<td>Second-Order Injection</td>
<td>Stored data trusted because it was parameterized once</td>
<td>Parameterize every query, including ones using your own stored data</td>
</tr>
<tr>
<td>Literal Injection</td>
<td>Raw SQL smuggled into an otherwise ORM-managed call</td>
<td>Avoid <code>literal()</code>/<code>raw()</code>, use the ORM's operator API instead</td>
</tr>
</tbody></table>
<p>None of these four bugs takes a skilled attacker to find. Each one is just a value that never made it into the ORM's parameterization path.</p>
<p>What's worth making part of your workflow: parameterize values, allowlist identifiers, treat data pulled from your own database as no safer than a fresh request, and treat <code>literal()</code>/<code>raw()</code> calls as exactly as dangerous as a dedicated raw-query method (no matter how safe the surrounding code looks).</p>
<p>Fixing these gaps is only half the picture. Knowing how someone actually goes looking for them (and chains them together during a real assessment) is the half most developers never see.</p>
<p>If you're curious about that offensive perspective, this <a href="https://hackita.it/articoli/sql-injection-orm/">deep dive on SQL injection through ORMs</a> walks through exactly how these four patterns get exploited step by step in a live penetration test.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Govern AI-Generated Infrastructure with Policy as Code and OPA [Full Handbook] ]]>
                </title>
                <description>
                    <![CDATA[ Modern models generate syntactically correct code nearly 100% of the time. Veracode's 2026 report puts it plainly: "Syntax is effectively solved." That reads like a milestone, but it's the reason you  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-govern-ai-generated-infrastructure-with-policy-as-code-and-opa-full-handbook/</link>
                <guid isPermaLink="false">6abb5903c40275b1ceb5b613</guid>
                
                    <category>
                        <![CDATA[ infrastructure ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ handbook ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Infrastructure as code ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Kayode Adeniyi ]]>
                </dc:creator>
                <pubDate>Tue, 29 Sep 2026 06:21:55 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/4208eef0-1dac-4b7d-89d7-28622a4d7825.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Modern models generate syntactically correct code nearly 100% of the time. Veracode's 2026 report puts it plainly: "Syntax is effectively solved."</p>
<p>That reads like a milestone, but it's the reason you have a problem.</p>
<p>The <a href="https://www.veracode.com/blog/2026-genai-code-security-report-ai-risk/">same report</a> tested more than a hundred models and found the average security pass rate at 56%, "barely changed from 55% in the first report", with roughly 44% of generation tasks introducing a risky vulnerability.</p>
<p>Functional correctness and security turn out to be separate problems, and only one of them is close to solved.</p>
<p>That result is neither an outlier nor new. At IEEE Security and Privacy in 2022, a team at NYU Tandon ran GitHub Copilot through 89 security-relevant scenarios, generated 1,689 programs, and found <a href="https://arxiv.org/abs/2108.09293">roughly 40% of them vulnerable</a> to something on MITRE's CWE Top 25. The paper was later selected as a <em>Communications of the ACM</em> research highlight.</p>
<p>In November 2024, Georgetown's Center for Security and Emerging Technology <a href="https://cset.georgetown.edu/publication/cybersecurity-risks-of-ai-generated-code/">evaluated five LLMs</a> and reported that almost half the snippets they produced contained bugs that could lead to exploitation. Four years, four independent teams, four methodologies, and the same answer each time.</p>
<p>At ACM CCS in 2023, Neil Perry, Megha Srivastava, Deepak Kumar, and Dan Boneh at Stanford <a href="https://arxiv.org/abs/2211.03622">put the developers into the experiment</a>: 47 participants, five security-related programming tasks, three languages, with 33 given an AI assistant and 14 not. The assisted group wrote significantly less secure code, and was <em>more</em> likely to believe the code it wrote was secure.</p>
<p>It's a small study, and it explains why the problem doesn't correct itself: the mechanism that would normally catch this (a developer looking harder at code that worries them) is the exact mechanism the tooling switches off.</p>
<p>Those studies all measure application code. But infrastructure code is the harder case, because a bad security group never fails: it works exactly as written, serving traffic to whoever asks, and the only thing that objects is a person reading a diff.</p>
<p>I can't review that volume by reading it, and neither can anybody else. What I can do is write the rules down in a form a computer checks on every change, which is what Policy as Code means.</p>
<p>In this handbook, I walk you through building that check. We'll point it at a real vulnerable repository, watch the obvious version of it clear five of the nine violations sitting in front of it, and then fix it.</p>
<p>By the end, you'll know how to:</p>
<ul>
<li><p>Write a Rego policy against the JSON that <code>terraform show -json</code> produces.</p>
</li>
<li><p>Test a policy the way you test application code, with fixtures and a coverage report.</p>
</li>
<li><p>Build a command-line gate with an exit-code contract that a CI pipeline can trust.</p>
</li>
<li><p>Block non-compliant workloads at Kubernetes admission time using CEL.</p>
</li>
<li><p>Have a model write a policy and let <code>opa check</code> and your own tests decide whether to keep it.</p>
</li>
<li><p>Authorise an AI agent's tool calls from the same policy engine.</p>
</li>
</ul>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-key-terms-in-plain-english">Key Terms in Plain English</a></p>
</li>
<li><p><a href="#heading-step-1-fetch-real-infrastructure-to-test-against">Step 1: Fetch Real Infrastructure to Test Against</a></p>
</li>
<li><p><a href="#heading-step-2-write-the-tests-before-the-policy">Step 2: Write the Tests Before the Policy</a></p>
</li>
<li><p><a href="#heading-step-3-write-the-policy-until-the-tests-pass">Step 3: Write the Policy Until the Tests Pass</a></p>
</li>
<li><p><a href="#heading-step-4-point-it-at-the-real-plan">Step 4: Point It at the Real Plan</a></p>
</li>
<li><p><a href="#heading-step-5-turn-the-verdict-into-an-exit-code">Step 5: Turn the Verdict into an Exit Code</a></p>
</li>
<li><p><a href="#heading-step-6-enforce-at-admission-time">Step 6: Enforce at Admission Time</a></p>
</li>
<li><p><a href="#heading-step-7-let-a-model-write-the-policy">Step 7: Let a Model Write the Policy</a></p>
</li>
<li><p><a href="#heading-step-8-govern-the-agent-itself">Step 8: Govern the Agent Itself</a></p>
</li>
<li><p><a href="#heading-step-9-what-i-got-wrong">Step 9: What I Got Wrong</a></p>
</li>
<li><p><a href="#heading-limits-of-the-check">Limits of the Check</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>You need:</p>
<ul>
<li><p>A terminal and a working <code>python3</code> (3.10 or newer).</p>
</li>
<li><p><code>jq</code>, for reading JSON at the command line.</p>
</li>
<li><p>About 700 MB of disk, because the AWS Terraform provider is large.</p>
</li>
<li><p>An Anthropic API key, but only for Step 7. Every other step runs offline.</p>
</li>
</ul>
<pre><code class="language-bash">mkdir policy-lab &amp;amp;&amp;amp; cd policy-lab
python3 -m venv .venv
source .venv/bin/activate
pip install anthropic

curl -L -o opa https://openpolicyagent.org/downloads/v1.20.2/opa_darwin_arm64_static
chmod +x opa &amp;amp;&amp;amp; sudo mv opa /usr/local/bin/

curl -L -o tf.zip https://releases.hashicorp.com/terraform/1.14.2/terraform_1.14.2_darwin_arm64.zip
unzip tf.zip &amp;amp;&amp;amp; sudo mv terraform /usr/local/bin/
</code></pre>
<p>On Windows, activate the environment with <code>.venv\Scripts\activate</code>, and swap the two download URLs for <code>opa_windows_amd64.exe</code> and <code>terraform_1.14.2_windows_amd64.zip</code>.</p>
<p>I ran everything below on <strong>OPA 1.20.2</strong>, <strong>Terraform 1.14.2,</strong> and <strong>AWS provider 6.x</strong>, on macOS. The policy syntax is stable across OPA 1.x.</p>
<p>If you're on OPA 0.x, every rule here needs <code>import rego.v1</code> added at the top, and I would upgrade instead. The violation counts depend on the AWS provider version only through the shape of the plan JSON, which has been stable since provider 5.</p>
<h2 id="heading-key-terms-in-plain-english">Key Terms in Plain English</h2>
<ul>
<li><p><strong>Policy as Code</strong>: a rule your organisation has already agreed on, written as a program that takes a proposed change and returns a decision.</p>
</li>
<li><p><strong>Rego</strong>: the query language Open Policy Agent evaluates. It's declarative: a rule body is a list of conditions that must all hold.</p>
</li>
<li><p><strong>Plan JSON</strong>: the machine-readable description of what Terraform is about to do, produced by <code>terraform show -json</code>. This is what the policy reads, so your <code>.tf</code> files never reach it.</p>
</li>
<li><p><strong>Admission control</strong>: the point inside the Kubernetes API server where an object can be rejected before it's stored.</p>
</li>
<li><p><strong>CEL</strong>: Common Expression Language, the small expression language Kubernetes evaluates natively inside the API server, with no webhook to deploy.</p>
</li>
<li><p><strong>False clearance</strong>: a resource the policy passed that it should have failed. Nobody ever notices one, so it goes unmeasured unless you go looking for it.</p>
</li>
</ul>
<img src="https://cdn.hashnode.com/uploads/covers/5f3a74bfc4d5973f55c91c8c/70640afe-46a8-4e6c-8953-3f97ed98254a.png" alt="Diagram titled &quot;Three decision points, three chances to say no&quot;, with three rows. The plan time row runs from terraform plan JSON to policy_gate.py to merge or block the PR. The admission time row runs from kubectl apply to ValidatingAdmissionPolicy to admit or reject the Pod. The call time row runs from agent picks a tool to the agent.authz decision to allow, deny or ask a human. Arrows point left to right from ingredient to product, and a caption reads one decision per boundary: CEL inside the API server, Rego either side." style="display: block;" width="600" height="400" loading="lazy">

<p>The same judgement happens in three places, and only the middle one is specific to Kubernetes.</p>
<h2 id="heading-step-1-fetch-real-infrastructure-to-test-against">Step 1: Fetch Real Infrastructure to Test Against</h2>
<p>I didn't want to invent a vulnerable Terraform file, because inventing one means inventing the bug, and then the policy only catches the bug I planted. So I went looking for code somebody else had written and published.</p>
<p><a href="https://github.com/bridgecrewio/terragoat">TerraGoat</a> is a deliberately vulnerable Terraform repository published by Bridgecrew. Fetch its EC2 module at a pinned commit:</p>
<pre><code class="language-bash">SHA=729f8da62c6a85ce4af5ad3d123de97776d954c4
curl -s "https://raw.githubusercontent.com/bridgecrewio/terragoat/$SHA/terraform/aws/ec2.tf" \
  | sed -n '77,96p'
</code></pre>
<pre><code class="language-hcl">resource "aws_security_group" "web-node" {
  # security group is open to the world in SSH port
  name        = "${local.resource_prefix.value}-sg"
  description = "${local.resource_prefix.value} Security Group"
  vpc_id      = aws_vpc.web_vpc.id

  ingress {
    from_port = 80
    to_port   = 80
    protocol  = "tcp"
    cidr_blocks = [
    "0.0.0.0/0"]
  }
  ingress {
    from_port = 22
    to_port   = 22
    protocol  = "tcp"
    cidr_blocks = [
    "0.0.0.0/0"]
  }
</code></pre>
<p>The comment on line two is TerraGoat's own, and port 22 open to the world is the finding it points at.</p>
<p>TerraGoat's module won't initialise on modern Terraform, because it still declares <code>type = "string"</code> in quotes, which Terraform 0.12 deprecated and 1.x rejects. So I lifted the resource into a minimal module of my own, replacing only the two references to TerraGoat's internal locals.</p>
<p>Create <code>main.tf</code>:</p>
<pre><code class="language-hcl">terraform {
  required_version = "&amp;gt;= 1.9"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~&amp;gt; 6.0"
    }
  }
}

# Mock credentials. This configuration is only ever planned, never applied,
# so the provider must not try to reach AWS.
provider "aws" {
  region                      = "us-west-2"
  access_key                  = "mock"
  secret_key                  = "mock"
  skip_credentials_validation = true
  skip_metadata_api_check     = true
  skip_requesting_account_id  = true
  skip_region_validation      = true
}

resource "aws_vpc" "web_vpc" {
  cidr_block = "10.0.0.0/16"
}

# Verbatim from bridgecrewio/terragoat, terraform/aws/ec2.tf, commit 729f8da.
# Only the two references to TerraGoat's own locals are replaced with literals.
resource "aws_security_group" "web-node" {
  name        = "terragoat-sg"
  description = "terragoat Security Group"
  vpc_id      = aws_vpc.web_vpc.id

  ingress {
    from_port = 80
    to_port   = 80
    protocol  = "tcp"
    cidr_blocks = [
    "0.0.0.0/0"]
  }
  ingress {
    from_port = 22
    to_port   = 22
    protocol  = "tcp"
    cidr_blocks = [
    "0.0.0.0/0"]
  }
  egress {
    from_port = 0
    to_port   = 0
    protocol  = "-1"
    cidr_blocks = [
    "0.0.0.0/0"]
  }
  depends_on = [aws_vpc.web_vpc]
  tags = {
    git_commit           = "d68d2897add9bc2203a5ed0632a5cdd8ff8cefb0"
    git_file             = "terraform/aws/ec2.tf"
    git_last_modified_at = "2020-06-16 14:46:24"
    git_org              = "bridgecrewio"
    git_repo             = "terragoat"
  }
}
</code></pre>
<p>Produce the plan JSON:</p>
<pre><code class="language-bash">terraform init
terraform plan -out=tfplan.binary
terraform show -json tfplan.binary &amp;gt; plan.json
</code></pre>
<p>The mock credentials matter: <code>terraform plan</code> on a create-only configuration never calls AWS, so with <code>skip_credentials_validation</code> and its three siblings the provider won't try to authenticate, and nothing is ever applied.</p>
<h2 id="heading-step-2-write-the-tests-before-the-policy">Step 2: Write the Tests Before the Policy</h2>
<p>The rule I wanted was: <em>no security group may expose an administrative port to the public internet.</em></p>
<p>That sounds like one line of code, and the tests are where I pin down why it's not. Create <code>policy/network_test.rego</code>:</p>
<pre><code class="language-rego">package terraform.network_test

import data.terraform.network

plan(resources) := {"resource_changes": resources}

security_group(ingress) := {
	"address": "aws_security_group.web",
	"type": "aws_security_group",
	"change": {"actions": ["create"], "after": {"ingress": [ingress]}},
}

test_denies_ssh_open_to_the_world if {
	fixture := plan([security_group({
		"from_port": 22,
		"to_port": 22,
		"protocol": "tcp",
		"cidr_blocks": ["0.0.0.0/0"],
	})])

	count(network.deny) == 1 with input as fixture
}

# A from_port equality check would miss this. The range check does not.
test_denies_wide_open_port_range if {
	fixture := plan([security_group({
		"from_port": 0,
		"to_port": 65535,
		"protocol": "tcp",
		"cidr_blocks": ["0.0.0.0/0"],
	})])

	count(network.deny) == 4 with input as fixture
}

test_denies_ipv6_route_to_the_world if {
	fixture := plan([security_group({
		"from_port": 22,
		"to_port": 22,
		"protocol": "tcp",
		"ipv6_cidr_blocks": ["::/0"],
	})])

	count(network.deny) == 1 with input as fixture
}

test_denies_standalone_ingress_rule if {
	fixture := plan([{
		"address": "aws_vpc_security_group_ingress_rule.ssh",
		"type": "aws_vpc_security_group_ingress_rule",
		"change": {"actions": ["create"], "after": {
			"from_port": 22,
			"to_port": 22,
			"ip_protocol": "tcp",
			"cidr_ipv4": "0.0.0.0/0",
			"cidr_ipv6": null,
		}},
	}])

	count(network.deny) == 1 with input as fixture
}

test_denies_deprecated_standalone_rule if {
	fixture := plan([{
		"address": "aws_security_group_rule.ssh",
		"type": "aws_security_group_rule",
		"change": {"actions": ["create"], "after": {
			"type": "ingress",
			"from_port": 22,
			"to_port": 22,
			"cidr_blocks": ["0.0.0.0/0"],
		}},
	}])

	count(network.deny) == 1 with input as fixture
}

test_allows_ssh_from_a_private_range if {
	fixture := plan([security_group({
		"from_port": 22,
		"to_port": 22,
		"protocol": "tcp",
		"cidr_blocks": ["10.0.0.0/8"],
	})])

	count(network.deny) == 0 with input as fixture
}

test_allows_https_from_the_world if {
	fixture := plan([security_group({
		"from_port": 443,
		"to_port": 443,
		"protocol": "tcp",
		"cidr_blocks": ["0.0.0.0/0"],
	})])

	count(network.deny) == 0 with input as fixture
}

# An all-protocols rule opens every port, whatever its port fields say.
# The first version of this test asserted the opposite and hid the bug.
test_denies_all_protocols_rule_open_to_the_world if {
	fixture := plan([security_group({
		"from_port": 0,
		"to_port": 0,
		"protocol": "-1",
		"cidr_blocks": ["0.0.0.0/0"],
	})])

	count(network.deny) == 4 with input as fixture
}

# Ports the policy cannot read are reported, never passed.
test_reports_a_world_open_rule_with_unreadable_ports if {
	fixture := plan([security_group({
		"from_port": 22,
		"to_port": null,
		"protocol": "tcp",
		"cidr_blocks": ["0.0.0.0/0"],
	})])

	count(network.deny) == 1 with input as fixture
}

test_reports_string_ports if {
	fixture := plan([security_group({
		"from_port": "22",
		"to_port": "22",
		"protocol": "tcp",
		"cidr_blocks": ["0.0.0.0/0"],
	})])

	count(network.deny) == 1 with input as fixture
}

# Unreadable ports on a rule that is not open to the world stay quiet.
test_ignores_unreadable_ports_on_a_private_range if {
	fixture := plan([security_group({
		"from_port": 22,
		"to_port": null,
		"protocol": "tcp",
		"cidr_blocks": ["10.0.0.0/8"],
	})])

	count(network.deny) == 0 with input as fixture
}

# `considered` drives the gate's pass-or-vacuous decision, so it needs
# tests of its own even though it takes no part in the judgement.
test_considers_every_ingress_bearing_type if {
	fixture := plan([
		security_group({}),
		{"address": "aws_vpc_security_group_ingress_rule.a", "type": "aws_vpc_security_group_ingress_rule", "change": {"actions": ["create"], "after": {}}},
		{"address": "aws_security_group_rule.b", "type": "aws_security_group_rule", "change": {"actions": ["create"], "after": {}}},
	])

	count(network.considered) == 3 with input as fixture
}

test_does_not_consider_unrelated_types if {
	fixture := plan([{
		"address": "aws_vpc.main",
		"type": "aws_vpc",
		"change": {"actions": ["create"], "after": {}},
	}])

	count(network.considered) == 0 with input as fixture
}
</code></pre>
<p>Here's what that file encodes:</p>
<ol>
<li><p><code>with input as fixture</code> swaps in a fake plan for one expression, which is how a policy is tested without a cloud account.</p>
</li>
<li><p><code>test_denies_wide_open_port_range</code> expects <strong>four</strong> violations, one per administrative port, because an ingress rule describes a range. A <code>0-65535</code> rule opens SSH exactly as wide as an explicit port 22 rule while sailing past an equality check.</p>
</li>
<li><p>Three tests cover three <em>other</em> shapes Terraform uses for the same idea: the IPv6 field, the modern standalone <code>aws_vpc_security_group_ingress_rule</code>, and the deprecated <code>aws_security_group_rule</code>. I didn't write these first, and Step 9 explains where they came from.</p>
</li>
<li><p>The two <code>test_allows_</code> cases matter as much as the denials. A policy that rejects everything passes every deny test and is worthless.</p>
</li>
<li><p>The last three tests arrived after the policy was already "finished", and Step 9 explains where they came from. An all-protocols rule opens every port whatever its port fields say, and a rule whose ports the policy can't read has to be reported.</p>
</li>
</ol>
<h2 id="heading-step-3-write-the-policy-until-the-tests-pass">Step 3: Write the Policy Until the Tests Pass</h2>
<p>Terraform describes ingress in four shapes, so the policy normalises all four into one set and then judges that set once. Create <code>policy/network.rego</code>:</p>
<pre><code class="language-rego"># METADATA
# title: No admin port is reachable from the public internet
# description: |
#   Terraform describes ingress in four different shapes. Each one is
#   normalised into a single `exposures` set first, so the judgement below
#   is written once and a new shape only costs one more helper rule.
#
#   Two things here are deliberate rather than incidental. An all-protocols
#   rule covers every port whatever its port fields say, and a rule whose
#   ports this policy cannot read is reported rather than passed.
package terraform.network

admin_ports := {22, 3389, 3306, 5432}

public_cidrs := {"0.0.0.0/0", "::/0"}

# The AWS provider writes from_port 0 and to_port 0 for an all-protocols
# rule, which opens every port, so the port fields cannot be read literally.
all_protocols := {"-1", "all"}

# Shape 1 and 2: inline ingress blocks, IPv4 and IPv6.
exposures contains exposure if {
	some resource in input.resource_changes
	resource.type == "aws_security_group"
	some ingress in resource.change.after.ingress
	some field in ["cidr_blocks", "ipv6_cidr_blocks"]
	some cidr in object.get(ingress, field, [])
	exposure := {
		"address": resource.address,
		"protocol": object.get(ingress, "protocol", ""),
		"from_port": object.get(ingress, "from_port", null),
		"to_port": object.get(ingress, "to_port", null),
		"cidr": cidr,
	}
}

# Shape 3: the standalone rule the AWS provider has recommended since v5.
exposures contains exposure if {
	some resource in input.resource_changes
	resource.type == "aws_vpc_security_group_ingress_rule"
	some field in ["cidr_ipv4", "cidr_ipv6"]
	cidr := object.get(resource.change.after, field, null)
	is_string(cidr)
	exposure := {
		"address": resource.address,
		"protocol": object.get(resource.change.after, "ip_protocol", ""),
		"from_port": object.get(resource.change.after, "from_port", null),
		"to_port": object.get(resource.change.after, "to_port", null),
		"cidr": cidr,
	}
}

# Shape 4: the deprecated standalone rule, still in most existing estates.
exposures contains exposure if {
	some resource in input.resource_changes
	resource.type == "aws_security_group_rule"
	resource.change.after.type == "ingress"
	some cidr in object.get(resource.change.after, "cidr_blocks", [])
	exposure := {
		"address": resource.address,
		"protocol": object.get(resource.change.after, "protocol", ""),
		"from_port": object.get(resource.change.after, "from_port", null),
		"to_port": object.get(resource.change.after, "to_port", null),
		"cidr": cidr,
	}
}

# The ports a rule really covers. Undefined when the policy cannot tell.
covered_ports(exposure) := [0, 65535] if {
	exposure.protocol in all_protocols
}

covered_ports(exposure) := [exposure.from_port, exposure.to_port] if {
	not exposure.protocol in all_protocols
	is_number(exposure.from_port)
	is_number(exposure.to_port)
}

deny contains msg if {
	some exposure in exposures
	exposure.cidr in public_cidrs

	# A rule covers a port if that port falls inside [from_port, to_port].
	range := covered_ports(exposure)
	some port in admin_ports
	port &amp;gt;= range[0]
	port &amp;lt;= range[1]

	msg := sprintf(
		"%s: ingress rule exposes port %d to %s",
		[exposure.address, port, exposure.cidr],
	)
}

# A rule open to the world whose ports this policy cannot read is reported.
# Passing it would be the policy guessing in the permissive direction.
deny contains msg if {
	some exposure in exposures
	exposure.cidr in public_cidrs
	not covered_ports(exposure)

	msg := sprintf(
		"%s: ingress rule to %s has ports this policy cannot evaluate (%v to %v)",
		[exposure.address, exposure.cidr, exposure.from_port, exposure.to_port],
	)
}

# Addresses this policy knows how to inspect. The gate uses this to tell
# "nothing violated" apart from "nothing examined".
considered contains resource.address if {
	some resource in input.resource_changes
	resource.type in {
		"aws_security_group",
		"aws_vpc_security_group_ingress_rule",
		"aws_security_group_rule",
	}
}
</code></pre>
<p>Reading that from the top:</p>
<ol>
<li><p>A rule body in Rego is a conjunction. Every line must hold, and <code>some ... in</code> lines iterate, so OPA explores every combination of resource, ingress rule, field, and port.</p>
</li>
<li><p>Three separate <code>exposures</code> rules define one set between them, which Rego calls an incremental definition. Adding a fifth shape later costs one more block and changes nothing below it.</p>
</li>
<li><p><code>object.get(ingress, field, [])</code> returns an empty list when a field is absent, so an IPv4-only rule doesn't error when the policy looks for <code>ipv6_cidr_blocks</code>.</p>
</li>
<li><p><code>covered_ports</code> is the safety valve here, because an all-protocols rule reports <code>0</code> to <code>0</code> in the plan while opening every port, so the port fields can't be read literally, and a rule whose ports are null or strings leaves the function undefined, which the second <code>deny</code> rule turns into a violation.</p>
</li>
<li><p><code>considered</code> isn't part of the judgement. It records which resources this policy can speak about at all, which Step 5 uses to avoid reporting a pass it hasn't earned.</p>
</li>
</ol>
<p>Run it:</p>
<pre><code class="language-bash">opa test policy
opa check --strict policy
opa fmt --diff policy
</code></pre>
<pre><code class="language-plaintext">PASS: 20/20
</code></pre>
<p>Add <code>-v</code> to <code>opa test</code> for a line per test.</p>
<p><code>opa check --strict</code> catches unsafe variables and shadowed imports, while <code>opa fmt --diff</code> prints nothing when the formatting is already canonical. OPA formats Rego with tabs. Both belong in CI, ahead of everything else.</p>
<p>The second policy is ownership tagging, so create <code>policy/tags.rego</code>:</p>
<pre><code class="language-rego"># METADATA
# title: Every managed resource carries ownership tags
# description: |
#   Terraform emits `tags: null` for a resource with no tags at all, so a
#   policy that reaches into `after.tags` skips exactly the resources with
#   the worst tagging. `tags_of` coerces that null to an empty object.
package terraform.tags

required_tags := {"owner", "cost-center", "data-classification"}

# Resource types that genuinely cannot carry tags.
untaggable := {"aws_iam_policy_attachment", "aws_route_table_association"}

in_scope contains resource if {
	some resource in input.resource_changes
	some action in resource.change.actions
	action in {"create", "update"}
	not resource.type in untaggable
}

tags_of(resource) := tags if {
	tags := resource.change.after.tags
	is_object(tags)
} else := {}

deny contains msg if {
	some resource in in_scope
	some tag in required_tags
	value := object.get(tags_of(resource), tag, "")
	trim_space(value) == ""
	msg := sprintf("%s: missing required tag %q", [resource.address, tag])
}

considered contains resource.address if {
	some resource in in_scope
}
</code></pre>
<p>Here's why those two lines look the way they do:</p>
<ol>
<li><p><code>trim_space(value) == ""</code> does the check. It has to, because in Rego only <code>false</code> and undefined are falsy, so an empty string is truthy. A bare existence check happily accepts <code>owner = ""</code>, which is compliance theatre of exactly the kind a tagging policy exists to stop.</p>
</li>
<li><p><code>tags_of</code>, with its <code>else := {}</code> branch, is the fix for a bug I wrote and only found in Step 9.</p>
</li>
</ol>
<h2 id="heading-step-4-point-it-at-the-real-plan">Step 4: Point It at the Real Plan</h2>
<p>Evaluate one package against the TerraGoat plan:</p>
<pre><code class="language-bash">opa eval --data policy --input plan.json --format pretty 'data.terraform.network.deny'
</code></pre>
<pre><code class="language-plaintext">[
  "aws_security_group.web-node: ingress rule exposes port 22 to 0.0.0.0/0"
]
</code></pre>
<p>The policy reports one violation, and stays quiet about port 80. It's open to the world in the same resource, because a public web server is the point of a public web server. A check that flags both is a check people learn to ignore.</p>
<p>Running one command per package doesn't scale, and Rego can aggregate across a namespace in a single query:</p>
<pre><code class="language-bash">opa eval --data policy --input plan.json --format pretty \
  'union({v | v := data.terraform[_].deny})'
</code></pre>
<pre><code class="language-plaintext">[
  "aws_security_group.web-node: ingress rule exposes port 22 to 0.0.0.0/0",
  "aws_security_group.web-node: missing required tag \"cost-center\"",
  "aws_security_group.web-node: missing required tag \"data-classification\"",
  "aws_security_group.web-node: missing required tag \"owner\"",
  "aws_vpc.web_vpc: missing required tag \"cost-center\"",
  "aws_vpc.web_vpc: missing required tag \"data-classification\"",
  "aws_vpc.web_vpc: missing required tag \"owner\""
]
</code></pre>
<p><code>{v | v := data.terraform[_].deny}</code> is a comprehension that collects the <code>deny</code> set from every package under <code>data.terraform</code>, and <code>union</code> flattens them. Drop a new policy file into that namespace and it's picked up with no change to the command.</p>
<h2 id="heading-step-5-turn-the-verdict-into-an-exit-code">Step 5: Turn the Verdict into an Exit Code</h2>
<p>A CI gate communicates through its exit status, and conflating two kinds of failure into one code is how a broken pipeline passes for a month. This tool uses the following:</p>
<table>
<thead>
<tr>
<th>Code</th>
<th>Verdict</th>
<th>Meaning</th>
</tr>
</thead>
<tbody><tr>
<td>0</td>
<td>pass</td>
<td>policies ran, examined resources, found nothing</td>
</tr>
<tr>
<td>1</td>
<td>fail</td>
<td>policies ran and found violations</td>
</tr>
<tr>
<td>2</td>
<td>vacuous</td>
<td>policies ran but examined nothing, so the result means nothing</td>
</tr>
<tr>
<td>2</td>
<td>broken</td>
<td>the tool or its input is unusable</td>
</tr>
</tbody></table>
<p>The fourth row is the one most gates get wrong. If a plan contains no resource any policy knows about, reporting a pass claims an assurance the run can't give. It gets its own verdict name and it doesn't exit 0.</p>
<p>Create <code>policy_gate.py</code>:</p>
<pre><code class="language-python">"""Evaluate a Terraform plan against a directory of Rego policies."""

import argparse
import json
import pathlib
import shutil
import subprocess
import sys

PASS, FAIL, BROKEN = 0, 1, 2

DENY_QUERY = "union({v | v := data.terraform[_].deny})"
CONSIDERED_QUERY = "union({v | v := data.terraform[_].considered})"


def die(message: str) -&amp;gt; None:
    print(f"policy-gate: {message}", file=sys.stderr)
    sys.exit(BROKEN)


def load_plan(path: pathlib.Path) -&amp;gt; dict:
    try:
        text = path.read_text()
    except OSError as exc:
        die(f"cannot read {path}: {exc.strerror}")
    try:
        return json.loads(text)
    except json.JSONDecodeError as exc:
        die(f"{path}:{exc.lineno}:{exc.colno}: invalid JSON: {exc.msg}")


def query(opa: str, policy_dirs: list[pathlib.Path], plan: pathlib.Path, expr: str) -&amp;gt; list:
    command = [opa, "eval", "--input", str(plan), "--format", "raw"]
    for directory in policy_dirs:
        command += ["--data", str(directory)]
    command.append(expr)

    result = subprocess.run(command, capture_output=True, text=True)
    if result.returncode != 0:
        die(f"opa failed: {result.stderr.strip() or result.stdout.strip()}")
    values = json.loads(result.stdout)
    # A rule that yields anything but strings is a policy bug, and sorting a
    # mixed list would surface it as an unrelated TypeError.
    for value in values:
        if not isinstance(value, str):
            die(f"{expr} produced a {type(value).__name__}; "
                "deny and considered rules must yield strings")
    return values


def main() -&amp;gt; int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--plan", required=True, type=pathlib.Path,
                        help="JSON from `terraform show -json`")
    parser.add_argument("--policy", required=True, nargs="+", type=pathlib.Path,
                        help="one or more directories of .rego files")
    parser.add_argument("--opa", default="opa", help="path to the opa binary")
    args = parser.parse_args()

    if shutil.which(args.opa) is None:
        die(f"{args.opa} is not on PATH")
    for directory in args.policy:
        if not directory.is_dir():
            die(f"{directory} is not a directory")

    plan = load_plan(args.plan)
    if "resource_changes" not in plan:
        die(f"{args.plan} has no resource_changes key; is it a Terraform plan?")

    considered = query(args.opa, args.policy, args.plan, CONSIDERED_QUERY)
    if not considered:
        # Reporting a pass here would claim an assurance the run cannot give.
        print(f"VACUOUS: no policy examined any of the "
              f"{len(plan['resource_changes'])} planned resource(s)", file=sys.stderr)
        return BROKEN

    violations = sorted(query(args.opa, args.policy, args.plan, DENY_QUERY))
    if violations:
        print(f"FAIL: {len(violations)} violation(s) "
              f"across {len(considered)} examined resource(s)", file=sys.stderr)
        for violation in violations:
            print(f"  - {violation}", file=sys.stderr)
        return FAIL

    print(f"PASS: {len(considered)} resource(s) examined, no violations")
    return PASS


if __name__ == "__main__":
    sys.exit(main())
</code></pre>
<p>Here's what the script does:</p>
<ol>
<li><p><code>argparse</code> marks <code>--plan</code> and <code>--policy</code> as <code>required=True</code>, and <code>--policy</code> takes <code>nargs="+"</code>, so an empty policy list raises an error at parse time.</p>
</li>
<li><p><code>load_plan</code> reports the line and column of a JSON syntax error, because <code>json.JSONDecodeError</code> carries <code>lineno</code> and <code>colno</code> and a gate that says only "invalid JSON" wastes somebody's afternoon.</p>
</li>
<li><p>Every failure path routes through <code>die</code>, which always exits 2, because a missing <code>opa</code>, an unreadable file, and a plan with no <code>resource_changes</code> key are all failures of the tool itself.</p>
</li>
<li><p>The <code>considered</code> query runs <em>before</em> the <code>deny</code> query. If nothing was examined, the run ends at <code>VACUOUS</code> and never gets the chance to print a pass.</p>
</li>
<li><p>Violations are sorted, so the same plan produces byte-identical output on every run and a diff of two CI logs means something.</p>
</li>
</ol>
<img src="https://cdn.hashnode.com/uploads/covers/5f3a74bfc4d5973f55c91c8c/8d6be1c9-d9ca-4379-8773-3e083cbe9475.png" alt="Terminal window titled policy-lab. Running opa test policy reports PASS colon 20 slash 20. Running python3 policy_gate.py with the TerraGoat plan prints FAIL colon 7 violations across 2 examined resources, listing one ingress rule exposing port 22 to 0.0.0.0/0 on aws_security_group.web-node and six missing required tags across aws_security_group.web-node and aws_vpc.web_vpc. echo dollar question mark returns 1." style="display: block;" width="600" height="400" loading="lazy">

<p>Seven violations across the two resources in this plan, and an exit code CI can act on.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f3a74bfc4d5973f55c91c8c/d0d457fc-a7f4-4f7f-8a13-c0bc352f9ed6.png" alt="Terminal window titled policy-lab. Running python3 policy_gate.py against an empty plan prints VACUOUS colon no policy examined any of the 0 planned resources, and echo dollar question mark returns 2, not 0." style="display: block;" width="600" height="400" loading="lazy">

<p>The same tool on an empty plan, where a gate answering "pass" would be lying.</p>
<p>The GitHub Actions workflow tests the policies before it uses them to judge anything:</p>
<pre><code class="language-yaml">name: policy

on: [pull_request]

jobs:
  policy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5

      - name: Install OPA
        run: |
          curl -L -o /usr/local/bin/opa \
            https://openpolicyagent.org/downloads/v1.20.2/opa_linux_amd64_static
          chmod +x /usr/local/bin/opa

      # The policies are code. Lint and test them before trusting them.
      - name: Check policy syntax
        run: opa check --strict policy

      - name: Verify formatting
        run: opa fmt --fail --diff policy

      - name: Test policies
        run: opa test policy --verbose --coverage --format json &amp;gt; coverage.json

      # Only now does anything get judged.
      - name: Evaluate Terraform plan
        run: python3 policy_gate.py --plan plan.json --policy policy
</code></pre>
<p>Roll this out with the gate reporting only, for a fortnight, before you let it block. A policy that looks obviously correct will fail on something structural in your real estate, and you would rather find that out from a log line than from a blocked release.</p>
<h2 id="heading-step-6-enforce-at-admission-time">Step 6: Enforce at Admission Time</h2>
<p>The gate in Step 5 checks what you intended to deploy. It doesn't see a <code>kubectl apply</code> from somebody's laptop, a vendor's Helm chart, or an operator creating Pods on its own schedule. For those, you need admission control, and Kubernetes now has it built in.</p>
<p><code>ValidatingAdmissionPolicy</code> has been generally available since <strong>v1.30</strong>, evaluating CEL inside the API server with no webhook to deploy or keep alive:</p>
<pre><code class="language-yaml">apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
  name: require-trusted-registry
spec:
  failurePolicy: Fail
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["pods"]
  variables:
    # A Pod has three container lists. A policy that reads only
    # spec.containers is bypassed by moving the image to an initContainer.
    - name: allImages
      expression: &amp;gt;-
        object.spec.containers.map(c, c.image) +
        object.spec.?initContainers.orValue([]).map(c, c.image) +
        object.spec.?ephemeralContainers.orValue([]).map(c, c.image)
  validations:
    - expression: &amp;gt;-
        variables.allImages.all(i, i.startsWith('registry.internal.example.com/'))
      messageExpression: &amp;gt;-
        'images must come from registry.internal.example.com: ' +
        variables.allImages.filter(i,
          !i.startsWith('registry.internal.example.com/')).join(', ')
      reason: Forbidden
</code></pre>
<p>The policy does nothing until a binding activates it, which is what lets you pilot on one namespace:</p>
<pre><code class="language-yaml">apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
  name: require-trusted-registry-binding
spec:
  policyName: require-trusted-registry
  validationActions: ["Deny"]
  matchResources:
    namespaceSelector:
      matchLabels:
        policy.example.com/enforce: "true"
</code></pre>
<p>Set <code>validationActions: ["Warn", "Audit"]</code>, label one namespace, watch for a week, and then switch to <code>["Deny"]</code> and widen the selector.</p>
<p>The <code>initContainers</code> handling matters, because it's the most common way an image-provenance policy gets bypassed, and the <code>?</code> optional-field syntax with <code>.orValue([])</code> is how you read a list that may be absent without the whole expression erroring.</p>
<p>I couldn't apply these two manifests, because I had no cluster to hand. They're checked against the v1 reference schema, and no real API server has admitted them, so treat them as a starting point and roll them out in <code>Warn</code> mode (which you should be doing anyway).</p>
<p>Two other engines are in wide production use, starting with <a href="https://kyverno.io/">Kyverno</a>. It <strong>graduated in the CNCF in March 2026</strong> with production use at Bloomberg, Coinbase, Deutsche Telekom, LinkedIn, and Spotify. Its policies are written in YAML, so a platform team needs no new language, and it handles generation, image-signature verification, and cleanup that built-in policies leave alone.</p>
<p><a href="https://open-policy-agent.github.io/gatekeeper/">OPA Gatekeeper</a> is the right answer when you want one Rego codebase covering Kubernetes <em>and</em> Terraform <em>and</em> CI, which is the position this tutorial builds toward. Mutation is now built in too: <code>MutatingAdmissionPolicy</code> became stable in <strong>v1.36</strong>.</p>
<h2 id="heading-step-7-let-a-model-write-the-policy">Step 7: Let a Model Write the Policy</h2>
<p>Policies are tedious, and models are good at tedious. So the obvious move is to have the model write them.</p>
<p>There's a catch that you can measure yourself in about a minute, and I do exactly that at the end of this step: a great deal of the Rego in public training data is <strong>Rego v0</strong>, the dialect that stopped parsing when OPA 1.0 shipped in January 2025. A model reaching for the most common pattern it has seen reaches for a dialect the current parser rejects.</p>
<p>A 2025 preprint from a group at the University of Calabria, <a href="https://arxiv.org/abs/2507.10584"><em>ARPaCCino</em></a>, reports the same effect on a Terraform case study: asked for Rego with no tools, Qwen3-30B and GPT-4o each produced 0 of 5 syntactically correct policies. Adding retrieval over the OPA documentation changed nothing. Giving the model a loop that could run <code>opa check</code> and read the errors took those to 4 of 5 and 5 of 5.</p>
<p>Those counts come from one small case study, so treat the direction as the durable part of the result.</p>
<p>A feedback loop is what fixed it, and <strong>the loop costs nothing</strong>, because you already built it out of <code>opa check --strict</code>, <code>opa fmt</code>, and <code>opa test</code>.</p>
<p>So build the loop with one inversion that makes it trustworthy. <strong>I write the tests, and the model writes the policy.</strong> Test fixtures are concrete and cheap to review, since you read a JSON blob and say "yes, that should be rejected" in three seconds. Rego with nested comprehensions takes real effort to read and is easy to misread. Put the human where review is cheap, and let the machine work where its output can be checked mechanically.</p>
<p>Create <code>policy_forge.py</code>:</p>
<pre><code class="language-python">"""Generate a Rego policy from a rule in English, and keep it only if the toolchain agrees."""

import argparse
import pathlib
import re
import subprocess
import sys
import tempfile

WRITTEN, REJECTED, BROKEN = 0, 1, 2

SYSTEM = """You write Open Policy Agent policies in Rego v1 (OPA 1.0+).

Rules:
- Use `if` on every rule body and `contains` for multi-value rules.
- Do not emit `import rego.v1`; it is redundant on OPA 1.0+.
- The input is the JSON from `terraform show -json`.
- Return one ```rego block and nothing else."""


def extract_rego(reply: str) -&amp;gt; str:
    blocks = re.findall(r"```rego\n(.*?)```", reply, re.DOTALL)
    if not blocks:
        raise ValueError("model returned no rego block")
    if len(blocks) &amp;gt; 1:
        raise ValueError(f"model returned {len(blocks)} rego blocks; expected one")
    return blocks[0]


def verify(opa: str, policy: str, tests: pathlib.Path) -&amp;gt; tuple[bool, str]:
    with tempfile.TemporaryDirectory() as tmp:
        bundle = pathlib.Path(tmp)
        # The tests keep their own name; the policy gets one that cannot
        # collide with it, whatever the caller named the test file.
        (bundle / "candidate_policy.rego").write_text(policy)
        (bundle / tests.name).write_text(tests.read_text())

        for command in ([opa, "check", "--strict"], [opa, "test"]):
            result = subprocess.run(command + [str(bundle)], capture_output=True, text=True)
            if result.returncode != 0:
                return False, (result.stdout + result.stderr).strip()
    return True, "opa check and opa test both passed"


def forge(rule: str, tests: pathlib.Path, ask, opa: str, attempts: int) -&amp;gt; str:
    transcript = [{
        "role": "user",
        "content": (
            f"Write a Rego policy for this rule:\n\n{rule}\n\n"
            f"It must satisfy these tests:\n\n```rego\n{tests.read_text()}```"
        ),
    }]

    for attempt in range(1, attempts + 1):
        reply = ask(transcript)
        policy = extract_rego(reply)
        ok, output = verify(opa, policy, tests)
        headline = next(iter(output.splitlines()), "no output from the toolchain")
        print(f"attempt {attempt}: {'PASS' if ok else 'FAIL'} - {headline}", file=sys.stderr)
        if ok:
            return policy
        transcript += [
            {"role": "assistant", "content": reply},
            {"role": "user", "content": f"The toolchain rejected that:\n\n{output}\n\nFix it."},
        ]

    raise RuntimeError(f"no policy survived {attempts} attempts")


def claude(model: str):
    import anthropic

    client = anthropic.Anthropic()

    def ask(transcript: list[dict]) -&amp;gt; str:
        response = client.messages.create(
            model=model,
            max_tokens=16000,
            system=[{
                "type": "text",
                "text": SYSTEM,
                "cache_control": {"type": "ephemeral"},
            }],
            thinking={"type": "adaptive"},
            messages=transcript,
        )
        return "".join(b.text for b in response.content if b.type == "text")

    return ask


def main() -&amp;gt; int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--rule", required=True, help="the policy, in one English sentence")
    parser.add_argument("--tests", required=True, type=pathlib.Path,
                        help="a _test.rego file you wrote by hand")
    parser.add_argument("--out", required=True, type=pathlib.Path,
                        help="where to write the policy, only if it passes")
    parser.add_argument("--model", default="claude-opus-5")
    parser.add_argument("--attempts", type=int, default=4)
    parser.add_argument("--opa", default="opa")
    args = parser.parse_args()

    if args.attempts &amp;lt; 1:
        print("policy-forge: --attempts must be at least 1", file=sys.stderr)
        return BROKEN
    if not args.tests.is_file():
        print(f"policy-forge: {args.tests} does not exist", file=sys.stderr)
        return BROKEN

    try:
        policy = forge(args.rule, args.tests, claude(args.model), args.opa, args.attempts)
    except RuntimeError as exc:
        print(f"policy-forge: {exc}; nothing written", file=sys.stderr)
        return REJECTED
    except ValueError as exc:
        print(f"policy-forge: {exc}", file=sys.stderr)
        return BROKEN

    args.out.write_text(policy)
    print(f"policy-forge: verified policy written to {args.out}", file=sys.stderr)
    return WRITTEN


if __name__ == "__main__":
    sys.exit(main())
</code></pre>
<pre><code class="language-bash">export ANTHROPIC_API_KEY=...
python3 policy_forge.py \
  --rule "No security group may expose an administrative port to the public internet." \
  --tests policy/network_test.rego \
  --out policy/generated.rego
</code></pre>
<p>Here's what the loop guarantees:</p>
<ol>
<li><p><strong>Verification runs as a subprocess:</strong> the model is never asked whether its policy is correct. <code>opa check</code> and <code>opa test</code> decide, and their exit codes are the only evidence the loop accepts.</p>
</li>
<li><p><strong>Failures go back as raw tool output, never summarised:</strong> compiler errors and test failures are the highest-signal feedback a model can receive, and paraphrasing throws away the part that helps.</p>
</li>
<li><p><strong>Nothing reaches disk until it passes:</strong> <code>forge</code> either returns a verified policy or raises, so there's no path where an unverified policy lands in the repository just because the retry budget ran out.</p>
</li>
</ol>
<p>I drove the loop with a scripted model so the result is reproducible without an API key. The three replies were a realistic v0-syntax policy, a realistic-but-wrong v1 policy, and the policy from Step 3:</p>
<pre><code class="language-plaintext">attempt 1: FAIL - 2 errors occurred during loading:
attempt 2: FAIL - policy/network_test.rego:63:
attempt 3: PASS - opa check and opa test both passed
</code></pre>
<p>Attempt 1 was Rego v0, the <code>deny[msg] { ... }</code> form, which stopped parsing when OPA 1.0 shipped in January 2025. And it's overwhelmingly what public training data contains. <code>opa check --strict</code> rejected it before it reached a test.</p>
<p>Attempt 2 was valid Rego v1. It would have passed review from most engineers, and it still scored only <strong>4 of 8</strong> on the suite. This is because it compared <code>ingress.from_port</code> against <code>admin_ports</code> directly, ignored the range, and read only <code>cidr_blocks</code>. <code>opa check</code> had no complaint, because the code was perfectly well-formed.</p>
<p><strong>A well-formed policy can still be the wrong policy, and the only thing in this loop that knows what you wanted is the test suite you wrote.</strong></p>
<p>A repair loop isn't monotonic, because each attempt is a fresh generation conditioned on an error message, with nothing carrying forward what already worked, so attempt four can lose a property attempt three had. Nothing in this design detects that, because the only thing being checked is the test suite you wrote.</p>
<p>Cap the retries, keep the suite growing, and treat every generated policy as a pull request that somebody approves before it merges.</p>
<h2 id="heading-step-8-govern-the-agent-itself">Step 8: Govern the Agent Itself</h2>
<p>An AI agent is also an actor, and it calls tools, so every tool call becomes an authorisation decision that something has to make.</p>
<p>The industry converged on this quickly: Amazon Bedrock AgentCore Policy reached general availability in March 2026, evaluating agent tool calls at the gateway in <a href="https://www.cedarpolicy.com/">Cedar</a>. The common open-source pattern is an OPA sidecar in front of an MCP tool gateway.</p>
<p>Research is pushing the same boundary harder: a 2026 preprint from the University of Washington group behind Defects4J, <a href="https://arxiv.org/abs/2603.20449"><em>Solver-Aided Verification of Policy Compliance in Tool-Augmented LLM Agents</em></a> (Winston, Winston, and Just), compiles natural-language policies into SMT constraints and blocks non-compliant calls with the Z3 solver.</p>
<p>They share one claim: <strong>a policy in the system prompt isn't enforcement.</strong> Enforcement is an interceptor sitting in the call path that can return "no" and stop the call from happening.</p>
<p>Create <code>agent/authz.rego</code>:</p>
<pre><code class="language-rego"># METADATA
# title: Agent tool-call authorisation
# description: |
#   Evaluated once per tool call, before the tool runs. The decision has
#   three values rather than two, because an agent worth deploying will
#   sometimes need to do something that a human, not the policy, should
#   approve.
package agent.authz

tool_grants := {
	"support": {"search_orders", "read_customer", "issue_refund"},
	"analytics": {"search_orders", "run_query"},
}

write_tools := {"issue_refund", "run_query"}

refund_ceiling_cents := 10000

# An unmapped role, an unknown tool or a malformed input all land here.
default decision := {"effect": "deny", "reasons": ["no matching grant"]}

decision := {"effect": effect_for(reasons), "reasons": reasons} if {
	count(granted) &amp;gt; 0
	reasons := escalations
}

granted contains role if {
	some role in input.agent.roles
	input.tool in object.get(tool_grants, role, set())
}

effect_for(reasons) := "allow" if count(reasons) == 0

effect_for(reasons) := "require_approval" if count(reasons) &amp;gt; 0

escalations contains reason if {
	input.tool in write_tools
	not input.session.human_in_loop
	reason := sprintf("%q writes state and the session is unattended", [input.tool])
}

# A refund with no readable amount cannot be checked against the ceiling,
# so it escalates. Silence here would clear the exact call an attacker
# would craft.
escalations contains reason if {
	input.tool == "issue_refund"
	not positive_amount
	reason := "refund amount is missing, unreadable, or not positive"
}

# A negative amount is a charge wearing a refund's name.
positive_amount if {
	amount := object.get(input, ["arguments", "amount_cents"], null)
	is_number(amount)
	amount &amp;gt; 0
}

escalations contains reason if {
	input.tool == "issue_refund"
	amount := object.get(input, ["arguments", "amount_cents"], null)
	is_number(amount)
	amount &amp;gt; refund_ceiling_cents
	reason := sprintf(
		"refund of %d cents exceeds the %d cent ceiling",
		[amount, refund_ceiling_cents],
	)
}

# Keyword matching is a coarse guard, and it is here to show the shape of an
# argument-level rule. Anything holding real data wants a SQL parser: this
# catches `DROP TABLE` and misses a statement that spells it another way.
destructive_sql := `(?i)\b(drop|truncate|delete|alter|grant|revoke)\b`

escalations contains reason if {
	input.tool == "run_query"
	regex.match(destructive_sql, object.get(input, ["arguments", "statement"], ""))
	reason := "statement contains a destructive SQL keyword"
}
</code></pre>
<p>The decision vocabulary is closed, and I'll state it plainly here:</p>
<table>
<thead>
<tr>
<th>Effect</th>
<th>What the caller does</th>
</tr>
</thead>
<tbody><tr>
<td>allow</td>
<td>run the tool</td>
</tr>
<tr>
<td>require_approval</td>
<td>pause, show the reasons to a human, run only on approval</td>
</tr>
<tr>
<td>deny</td>
<td>refuse, and don't offer an approval path</td>
</tr>
</tbody></table>
<p>Here's why the policy is shaped that way:</p>
<ol>
<li><p><code>default decision</code> <strong>is deny:</strong> an unrecognised tool, a role you forgot to map, or a malformed input all end up there. A policy that defaults to allow fails open on exactly the inputs nobody anticipated, which is the set an attacker picks from.</p>
</li>
<li><p><strong>Three values:</strong> binary authorisation forces a choice between blocking useful work and permitting dangerous work, and the third value is what makes a high-autonomy agent tolerable.</p>
</li>
<li><p><strong>Reasons come back as a set:</strong> every applicable reason is collected. When somebody gets an approval prompt at three in the morning, "refund of 250000 cents exceeds the 10000 cent ceiling" tells them what to do. "Policy violation" does not.</p>
</li>
<li><p><strong>Arguments are inspected too:</strong> <code>issue_refund</code> is routine at £5 and serious at £2,500, so tool-name granularity is far too coarse for agents, given that the agent chooses the arguments.</p>
</li>
</ol>
<p>Fifteen tests cover the decision table, including an agent with an empty role list, a refund with no amount at all, and a query that hides <code>DROP</code> behind a newline:</p>
<pre><code class="language-bash">opa test agent -v
</code></pre>
<pre><code class="language-plaintext">PASS: 15/15
</code></pre>
<p>Serve it and try a call:</p>
<pre><code class="language-bash">opa run --server --addr localhost:8181 agent/
</code></pre>
<pre><code class="language-bash">curl -s localhost:8181/v1/data/agent/authz/decision \
  -d '{"input":{"agent":{"roles":["support"]},"tool":"issue_refund",
       "arguments":{"amount_cents":250000},"session":{"human_in_loop":true}}}' | jq .result
</code></pre>
<pre><code class="language-json">{
  "effect": "require_approval",
  "reasons": [
    "refund of 250000 cents exceeds the 10000 cent ceiling"
  ]
}
</code></pre>
<p>The policy is inert until something refuses to proceed on its answer. That's the client:</p>
<pre><code class="language-python">import json
import urllib.request

OPA_URL = "http://localhost:8181/v1/data/agent/authz/decision"


class PolicyDenied(Exception):
    pass


class ApprovalRequired(Exception):
    pass


def authorize(agent, tool, arguments, session):
    payload = json.dumps({"input": {
        "agent": agent, "tool": tool,
        "arguments": arguments, "session": session,
    }}).encode()
    req = urllib.request.Request(
        OPA_URL, data=payload, headers={"Content-Type": "application/json"}
    )
    with urllib.request.urlopen(req, timeout=2) as resp:
        body = json.load(resp)

    # OPA returns {} with a 200 when a query matches nothing. Fail closed.
    decision = body.get("result", {"effect": "deny", "reasons": ["policy unavailable"]})

    if decision["effect"] == "deny":
        raise PolicyDenied("; ".join(decision["reasons"]))
    if decision["effect"] == "require_approval":
        raise ApprovalRequired("; ".join(decision["reasons"]))
    return decision
</code></pre>
<pre><code class="language-plaintext">search_orders    -&amp;gt; ALLOWED
issue_refund     -&amp;gt; NEEDS APPROVAL (refund of 250000 cents exceeds the 10000 cent ceiling)
delete_account   -&amp;gt; DENIED (no matching grant)
</code></pre>
<p>Note <code>body.get("result", ...)</code>: OPA returns <code>{}</code> with a 200 status when a query matches nothing, so a bare <code>body["result"]</code> raises <code>KeyError</code>, and depending on how your agent framework handles exceptions that may fail <em>open</em>. Every layer defaults to deny, including the parsing.</p>
<p>Call <code>authorize()</code> from your framework's tool-execution hook, before the tool function runs. It's about fifteen lines, and it turns a system prompt's polite suggestions into an actual boundary.</p>
<h2 id="heading-step-9-what-i-got-wrong">Step 9: What I Got Wrong</h2>
<p>Steps 2 and 3 show the finished policies. I reached for something simpler first (the version most tutorials stop at), and the gap between that and what you have just read is the most useful thing here.</p>
<h3 id="heading-the-tagging-policy-skipped-the-worst-resources">The Tagging Policy Skipped the Worst Resources</h3>
<p>My naïve <code>in_scope</code> rule ended with <code>resource.change.after.tags</code>, which reads as "only resources that have tags".</p>
<p>What it actually does is worse than that, because Terraform emits <code>tags: null</code> for a resource with <strong>no tags at all</strong>, and an undefined lookup makes the rule body fail, so the resource drops out of scope entirely.</p>
<p>The TerraGoat plan has two resources: the security group carries five <code>git_*</code> tags and no ownership tags, while the VPC carries nothing at all.</p>
<pre><code class="language-bash">jq -r '.resource_changes[] | "\(.address): tags=\(.change.after.tags | type)"' plan.json
</code></pre>
<pre><code class="language-plaintext">aws_security_group.web-node: tags=object
aws_vpc.web_vpc: tags=null
</code></pre>
<pre><code class="language-bash">opa eval --data naive  --input plan.json --format pretty 'count(data.terraform.tags.deny)'
opa eval --data policy --input plan.json --format pretty 'count(data.terraform.tags.deny)'
</code></pre>
<pre><code class="language-plaintext">3
6
</code></pre>
<p>The three it missed were all on the completely untagged resource, so the policy flagged the resource with some tags and silently cleared the one with none.</p>
<p><code>tags_of</code> with its <code>else := {}</code> branch is the fix, and it's three lines.</p>
<h3 id="heading-the-network-policy-read-one-of-four-shapes">The Network Policy Read One of Four Shapes</h3>
<p>My naïve network policy read <code>aws_security_group</code> and <code>cidr_blocks</code>, which is what every tutorial shows, but Terraform has four ways to express the same ingress rule.</p>
<p>![Diagram titled "Four ways Terraform describes one ingress rule". Four boxes are shown. Top left, aws_security_group.ingress[].cidr_blocks, filled pale blue and labelled read. The other three are outlined in red with red hatching and labelled not read: aws_security_group.ingress[].ipv6_cidr_blocks, aws_vpc_security_group_ingress_rule.cidr_ipv4, and the deprecated aws_security_group_rule. A key states that solid blue fill means the policy looks here and red hatch means it does not.](<a href="https://cdn.hashnode.com/uploads/covers/5f3a74bfc4d5973f55c91c8c/ab61d36d-78bf-45f7-a073-e7013216e3e1.png">https://cdn.hashnode.com/uploads/covers/5f3a74bfc4d5973f55c91c8c/ab61d36d-78bf-45f7-a073-e7013216e3e1.png</a> align="center")</p>
<p>One rule, four encodings, and the naïve policy read only the top-left one.</p>
<p>I planned a second real configuration with three security groups that open SSH to the world using the shapes the policy didn't read. Both versions are in <code>code/</code>, so this reproduces:</p>
<pre><code class="language-bash">opa eval --data naive --input plan-evasion.json \
  --format pretty 'data.terraform.network.deny'
</code></pre>
<pre><code class="language-plaintext">[]
</code></pre>
<p>That is three publicly reachable SSH ports, zero violations, and a gate that would have printed <code>PASS</code> and exited 0.</p>
<h3 id="heading-a-test-that-was-holding-a-hole-open">A Test That Was Holding a Hole Open</h3>
<p>The port range had a second problem, and my own test suite was protecting it. I had written a case called <code>test_tolerates_null_ports</code>, asserting that an ingress rule with <code>protocol: "-1"</code> produced no violations, on the reasoning that a comparison against a null port shouldn't crash the policy.</p>
<p>An all-protocols rule opens every port, and the AWS provider records it as <code>from_port: 0</code> and <code>to_port: 0</code>. The the range check read it literally as the single port zero, so the most permissive rule in AWS scored clean.</p>
<p>The plan is in <code>code/plan-all-protocols.json</code>, and it's one resource:</p>
<pre><code class="language-bash">jq -c '.resource_changes[] | select(.type=="aws_security_group")
       | .change.after.ingress[0] | {protocol,from_port,to_port,cidr_blocks}' \
   plan-all-protocols.json
opa eval --data naive --input plan-all-protocols.json \
   --format pretty 'data.terraform.network.deny'
</code></pre>
<pre><code class="language-plaintext">{"protocol":"-1","from_port":0,"to_port":0,"cidr_blocks":["0.0.0.0/0"]}
[]
</code></pre>
<p>Every protocol and every port, open to the whole internet, and a test I wrote on purpose certified it as fine. The fix is <code>covered_ports</code>, which maps an all-protocols rule onto the full range and goes undefined for ports it can't read, with a second <code>deny</code> rule that reports the undefined case. That test is gone and four took its place:</p>
<pre><code class="language-plaintext">opa eval --data policy --input plan-all-protocols.json --format pretty 'data.terraform.network.deny'
</code></pre>
<pre><code class="language-plaintext">[
  "aws_security_group.wide_open: ingress rule exposes port 22 to 0.0.0.0/0",
  "aws_security_group.wide_open: ingress rule exposes port 3306 to 0.0.0.0/0",
  "aws_security_group.wide_open: ingress rule exposes port 3389 to 0.0.0.0/0",
  "aws_security_group.wide_open: ingress rule exposes port 5432 to 0.0.0.0/0"
]
</code></pre>
<p>Step 7 argues that the test suite is the only artifact in the loop that knows what you wanted. This is the cost of that property: a test that's wrong is a specification that's wrong, and nothing downstream of it will argue.</p>
<h3 id="heading-what-the-numbers-actually-were">What the Numbers Actually Were</h3>
<img src="https://cdn.hashnode.com/uploads/covers/5f3a74bfc4d5973f55c91c8c/ce90751d-1eb9-45d1-8514-6481d9b3612d.png" alt="orizontal bar chart titled &quot;The naive policy missed 5 of the 9 violations present&quot;, showing violations found as a fraction of violations present on two real Terraform plans. For evasion plan open ports the naive policy scores 0.000 in red hatching and the hardened policy scores 1.000 in blue hatching. For terragoat plan missing tags the naive policy scores 0.500 in red and the hardened policy scores 1.000 in blue. For terragoat plan open ports both score 1.000, drawn in grey." style="display: block;" width="600" height="400" loading="lazy">

<p>Both versions score 1.000 on the plan I designed the policy against. The gap only appears on the plan I did not.</p>
<p>Across both real plans, <strong>the naïve policies found 4 of the 9 violations present</strong>. They scored 1.000 on the TerraGoat security group, which is the case I had in mind while writing them, and 0.000 and 0.500 on the two cases I did not.</p>
<h3 id="heading-the-part-that-genuinely-surprised-me">The Part That Genuinely Surprised Me</h3>
<p>I assumed test coverage would have caught this, and it doesn't. I reconstructed the naïve tagging policy with the two tests I originally wrote for it:</p>
<pre><code class="language-bash">opa test . --coverage --format json | jq '{overall: .coverage}'
</code></pre>
<pre><code class="language-plaintext">{
  "overall": 100
}
</code></pre>
<p><strong>The naïve policy scored 100% coverage, passed 2 of 2 tests, and cleared a resource that carried no tags at all.</strong></p>
<p>Coverage measures which lines of a policy your tests executed, and says nothing at all about which shapes of input you failed to imagine. For policy code, this is the entire failure mode. Coverage is worth reporting, and the evidence that a policy actually works comes from running it against infrastructure you didn't write.</p>
<h2 id="heading-limits-of-the-check">Limits of the Check</h2>
<p>Here's what this gate still can't do, because its false clearances matter more than its catches.</p>
<h3 id="heading-1-unknown-values-are-invisible">1. Unknown Values Are Invisible</h3>
<p>Terraform marks anything it can't resolve until apply time as unknown, which appears as <code>null</code> in the plan JSON alongside an <code>after_unknown</code> map. A policy reading <code>change.after.some_field</code> doesn't fire when that field is unknown.</p>
<p>This bites hardest on cross-resource rules, where "every bucket has a public access block" is genuinely hard at plan time, because the block references a bucket ID that's usually <code>(known after apply)</code>.</p>
<h3 id="heading-2-it-sees-the-plan-and-only-the-plan">2. It Sees the Plan, and Only the Plan</h3>
<p>Anything applied outside the pipeline, changed in a console, or drifted since creation stays invisible to it. Plan-time checks and admission-time checks have <em>different</em> blind spots, and both leave work for a periodic scan of deployed state.</p>
<h3 id="heading-3-coverage-of-controls-isnt-measurable-from-inside">3. Coverage of Controls Isn't Measurable from Inside</h3>
<p>A hundred green checks say nothing about the rules nobody wrote. Keep the mapping from your control requirements to your policy files somewhere explicit and audit it on a schedule, because the gate can't tell you what it was never asked.</p>
<h3 id="heading-4-the-cidr-list-is-an-exact-match">4. The CIDR List is an Exact Match</h3>
<p><code>public_cidrs</code> holds <code>0.0.0.0/0</code> and <code>::/0</code> and nothing else, so a rule opening <code>0.0.0.0/1</code> reaches half the internet and passes. Widening it means deciding which prefix lengths count as public and carving out RFC 1918 space, and that decision belongs to your organisation.</p>
<h3 id="heading-5-four-shapes-is-what-i-found">5. Four Shapes is What I Found</h3>
<p>The <code>exposures</code> set covers the four encodings I went looking for, and AWS offers more. Security group references (<code>security_groups</code>), prefix lists, and <code>self</code> rules are all ways to reach a port that this policy doesn't model, and it never sees them. I would expect a fifth shape to turn up the first time this runs against a large estate.</p>
<h3 id="heading-6-regulatory-dates-move">6. Regulatory Dates Move</h3>
<p>If you're building toward the EU AI Act, the Digital Omnibus published in July 2026 pushed Annex III high-risk obligations from 2 August 2026 to <strong>2 December 2027</strong>, and Annex I obligations to 2 August 2028, while the Article 50 transparency duties kept their original 2 August 2026 date.</p>
<p>Encode the controls, and look the dates up in the <a href="https://artificialintelligenceact.eu/implementation-timeline/">official timeline</a> every time you need one, including when a blog post from last quarter tells you otherwise (this one included).</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Models produce infrastructure code that parses almost every time, is secure about 56% of the time, and arrives faster than anybody can read it. Manual review stopped being a real control somewhere in that gap. The rules were always meant to be executable, and the volume is what finally forced the issue.</p>
<p>In this tutorial, you:</p>
<ul>
<li><p>Pulled a real vulnerable security group from TerraGoat at commit <code>729f8da</code> and planned it with Terraform 1.14.2.</p>
</li>
<li><p>Wrote 20 policy tests covering four Terraform encodings of one ingress rule, and got them to pass on OPA 1.20.2.</p>
</li>
<li><p>Caught 7 real violations in the TerraGoat plan, while correctly ignoring port 80.</p>
</li>
<li><p>Built a gate with a four-verdict contract that exits 2 when it examined nothing.</p>
</li>
<li><p>Watched the naïve version find 4 of 9 violations while reporting 100% test coverage, and hardened it to find 9 of 9.</p>
</li>
<li><p>Wired a generation loop where <code>opa check</code> and your own tests decide what reaches disk.</p>
</li>
<li><p>Authorised agent tool calls from the same engine, with 15 tests and a default of deny.</p>
</li>
</ul>
<p>My policies handled the cases I wrote them for and missed two I hadn't imagined, and every signal available to me (tests passing, coverage at 100%, and a clean <code>opa check</code>) agreed they were fine. The one thing that disagreed was infrastructure somebody else had written. Point your policies at code you didn't write, early, and keep the failures.</p>
<p>All the code, the policies, the plan JSON, and the scripts that build the figures are in the <code>code/</code> directory alongside this handbook. The figures regenerate with <code>python3 build/make_images.py</code> and <code>python3 build/make_terminals.py</code>. The terminal screenshots re-run their commands at build time, so they can't drift from the truth.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Prevent Poisoned GitHub Actions Dependencies ]]>
                </title>
                <description>
                    <![CDATA[ Your workflow uses actions/checkout@v4. Today, that tag points to a vetted release. Tomorrow, a compromised maintainer or attacker moves the tag to malicious code. Your pipeline runs it with access to ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-prevent-poisoned-github-actions-dependencies/</link>
                <guid isPermaLink="false">6aad60c103851fe98f2df4a2</guid>
                
                    <category>
                        <![CDATA[ DevSecOps ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ GitHub ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ ILYAS RUFAI ]]>
                </dc:creator>
                <pubDate>Fri, 18 Sep 2026 16:03:13 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/c5ac930c-aa14-434e-a712-48102cc88e44.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Your workflow uses <code>actions/checkout@v4</code>. Today, that tag points to a vetted release. Tomorrow, a compromised maintainer or attacker moves the tag to malicious code. Your pipeline runs it with access to <code>GITHUB_TOKEN</code>.</p>
<p>Third-party <strong>Actions are dependencies</strong>. A floating tag behaves like an unpinned package dependency. If the reference changes, the workflow can execute different code without any change to your repository.</p>
<p>This article shows you how to prevent poisoned continuous integration (CI) dependencies by pinning Actions to full commit SHAs, validating those pins against release tags, restricting permitted actions, and automating reviewed updates with Dependabot.</p>
<p><strong>Who this is for:</strong> Platform and DevSecOps engineers securing GitHub Actions workflows.</p>
<p><strong>Prerequisites:</strong></p>
<ul>
<li><p>Repository admin access</p>
</li>
<li><p>Workflows using <code>uses: org/action@ref</code> syntax</p>
</li>
</ul>
<h2 id="heading-the-quick-reference">The Quick Reference:</h2>
<p>Here's what you'll learn how to do here:</p>
<ul>
<li><p>Pin every <code>uses:</code> reference to a <strong>full commit SHA</strong>, not a moving tag such as <code>@v4</code>.</p>
</li>
<li><p>Validate that each recorded SHA matches the release you reviewed before approving it.</p>
</li>
<li><p>Maintain an <strong>allowlist</strong> of permitted actions at the organization or repository level.</p>
</li>
<li><p>Enable <strong>Dependabot</strong> for GitHub Actions version bumps with review.</p>
</li>
<li><p>Prefer official or verified creators. Mirror critical actions internally if needed.</p>
</li>
<li><p>Verify pins in pull request checks before merge.</p>
</li>
</ul>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-the-quick-reference">The Quick Reference</a></p>
</li>
<li><p><a href="#heading-why-floating-tags-fail">Why Floating Tags Fail</a></p>
</li>
<li><p><a href="#heading-pin-actions-to-commit-shas">Pin Actions to Commit SHAs</a></p>
</li>
<li><p><a href="#heading-validate-the-commit-shas">Validate the Commit SHAs</a></p>
</li>
<li><p><a href="#heading-enable-dependabot-for-actions">Enable Dependabot for Actions</a></p>
</li>
<li><p><a href="#heading-allowlist-actions">Allowlist Actions</a></p>
</li>
<li><p><a href="#heading-enforce-pins-in-pull-requests">Enforce Pins in Pull Requests</a></p>
</li>
<li><p><a href="#heading-vet-third-party-actions">Vet Third-Party Actions</a></p>
</li>
<li><p><a href="#heading-how-to-verify-this-works">How to Verify This Works</a></p>
</li>
<li><p><a href="#heading-when-this-breaks-down">When This Breaks Down</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
<li><p><a href="#heading-references">References</a></p>
</li>
</ul>
<h2 id="heading-why-floating-tags-fail">Why Floating Tags Fail</h2>
<p>GitHub Actions lets you reference a dependency with a branch name, version tag, or commit SHA. These references don't provide the same level of stability. A branch can change at any time, and a version tag can be moved to a different commit after you review it. A commit SHA identifies one specific revision.</p>
<p>The table below compares the common reference styles and shows why a moving tag creates a supply chain risk:</p>
<table>
<thead>
<tr>
<th>Reference style</th>
<th>Risk</th>
</tr>
</thead>
<tbody><tr>
<td><code>@main</code></td>
<td>Runs whatever code is on the branch when the workflow starts</td>
</tr>
<tr>
<td><code>@v4</code></td>
<td>The tag can move, so the same label can execute different code</td>
</tr>
<tr>
<td><code>@v4.2.1</code></td>
<td>More specific, but still a mutable tag</td>
</tr>
<tr>
<td><code>@abc1234...</code> (full SHA)</td>
<td>Identifies one immutable commit</td>
</tr>
</tbody></table>
<p>The first three references fail because the name doesn't permanently identify the code that GitHub will execute. A full SHA solves that specific problem by making the workflow change only when someone changes the reference in the repository.</p>
<p><strong>Key idea:</strong> CI pipelines deserve the same dependency discipline as application code.</p>
<h2 id="heading-pin-actions-to-commit-shas">Pin Actions to Commit SHAs</h2>
<p>Pinning an Action means replacing its branch or version tag with the full SHA of the commit you reviewed. GitHub then checks out that exact revision, even if the publisher later moves the original tag.</p>
<p>This first example is <strong>bad</strong> because both references use mutable version tags. The workflow may run different Action code later without a corresponding change in your repository:</p>
<pre><code class="language-yaml">- uses: actions/checkout@v4
- uses: actions/setup-node@v4
</code></pre>
<p>This second example is <strong>good</strong> because each reference uses a full 40-character commit SHA. The comments preserve the readable release versions for maintainers, but GitHub uses the SHA rather than the comments:</p>
<pre><code class="language-yaml">- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@4992456781334f2795ae9dd169f5041797d85689 # v4.4.0
</code></pre>
<p>Find the SHA on the action's GitHub release page or:</p>
<pre><code class="language-bash">gh api repos/actions/checkout/git/ref/tags/v4.2.2 --jq '.object.sha'
</code></pre>
<p>Use the exact release tag you reviewed. Add a comment with the human-readable version for maintainability. The comment helps a reviewer, but it's not part of the security control. GitHub executes the commit identified by the SHA.</p>
<p>Don't use a shortened SHA. A full 40-character SHA makes accidental collisions and ambiguous reviews less likely.</p>
<h2 id="heading-validate-the-commit-shas">Validate the Commit SHAs</h2>
<p>A full SHA protects the workflow from a tag moving after you merge it, but you still need to validate the SHA before approving it. The release tag and commit should be reviewed together. This check compares the commit behind the reviewed tag with the commit recorded in the workflow:</p>
<pre><code class="language-bash">#!/usr/bin/env bash
set -euo pipefail

repository="actions/checkout"
release_tag="v4.2.2"
expected_sha="11bd71901bbe5b1630ceea73d27597364c9af683"

actual_sha="$({
  git ls-remote "https://github.com/${repository}.git" \
    "refs/tags/${release_tag}" "refs/tags/${release_tag}^{}"
} | awk -v tag="refs/tags/${release_tag}" '
  $2 == tag "^{}" { print $1; found = 1 }
  $2 == tag { fallback = $1 }
  END { if (!found) print fallback }
')"

if [[ "${actual_sha}" != "${expected_sha}" ]]; then
  printf 'SHA mismatch for %s %s\n' "${repository}" "${release_tag}" &gt;&amp;2
  printf 'Expected: %s\nActual:   %s\n' "${expected_sha}" "${actual_sha}" &gt;&amp;2
  exit 1
fi

printf 'Validated %s@%s\n' "${repository}" "${expected_sha}"
</code></pre>
<p>This is release-reference validation, not a claim that a repository is trustworthy. Read the action source, review its permissions, and record why you approved the dependency. For higher-assurance environments, mirror critical actions internally and validate the mirrored artifact through your normal repository controls.</p>
<h2 id="heading-enable-dependabot-for-actions">Enable Dependabot for Actions</h2>
<p>Dependabot is GitHub's automated dependency-update service. For GitHub Actions, it checks the workflow references for newer releases and opens pull requests that update them. This gives you a reviewable place to inspect and approve an Action update instead of changing pins manually or using floating tags.</p>
<p>Create <code>.github/dependabot.yml</code>:</p>
<pre><code class="language-yaml">version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"
    groups:
      actions:
        patterns:
          - "*"
</code></pre>
<p>Dependabot opens pull requests when pinned SHAs have newer releases. Review and merge like application dependencies.</p>
<p>Don't configure Dependabot to merge these updates automatically until you have a review process for action changes. An update can contain new permissions, changed scripts, or a new transitive dependency.</p>
<p>For each update pull request, compare the old and new commit, inspect the action's release notes, and review changes to <code>action.yml</code> JavaScript bundles, shell scripts, and workflow permissions. Confirm that the new commit belongs to the release you intended to adopt. Run the workflow in a test repository or environment when the action handles deployment, publishing, credentials, or other high-impact operations.</p>
<p>The goal isn't to reject every update. The goal is to make the trust decision visible and repeatable. A reviewer should be able to answer three questions before merging: what code changed, why is the new version needed, and what permissions can that code use?</p>
<h2 id="heading-allowlist-actions">Allowlist Actions</h2>
<p>An Action allowlist is a repository or organization policy that limits which publishers and repositories workflows are allowed to call. It reduces the chance that a contributor introduces an unknown or unreviewed Action into a trusted pipeline.</p>
<p>Organization administrators can configure it at <strong>Settings → Actions → Policies → Allow specified actions</strong>. Choose the narrowest policy that matches your workflows, then add only the Action repositories your teams have reviewed.</p>
<p>Example allowlist patterns:</p>
<pre><code class="language-text">actions/checkout@*
actions/setup-node@*
actions/cache@*
docker/*
my-org/*
</code></pre>
<p>With this policy, Actions that don't match the approved patterns are denied. Repositories under the organization inherit the policy, subject to the organization's GitHub plan and repository settings.</p>
<p>For a single repo without an org policy, use <strong>Settings → Actions → General → Allow actions created by GitHub, and select non-GitHub actions</strong> and restrict to verified creators only.</p>
<p>An allowlist limits which action identities may run. It doesn't replace SHA pinning. A permitted action should still be referenced by a reviewed full SHA in every workflow.</p>
<h2 id="heading-enforce-pins-in-pull-requests">Enforce Pins in Pull Requests</h2>
<p>Run a workflow linter in CI and add a repository check that fails when a workflow introduces a non-SHA reference. Pin the checker itself before using it in a protected workflow. Replace the placeholder below with the full commit SHA you verified for the actionlint release you selected:</p>
<pre><code class="language-yaml">name: actionlint

on:
  pull_request:

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
      - uses: rhysd/actionlint@03d0035246f3e81f36aed592ffb4bebf33a03106 # v1.7.7
        with:
          args: -color
</code></pre>
<p>Resolve the release tag, review the commit, and record the full 40-character SHA just as you did for <code>actions/checkout</code>.</p>
<p>You can also use a small check to catch common floating references. Treat this as a backup signal, not a YAML parser or a complete supply chain policy:</p>
<pre><code class="language-yaml">      - name: Reject floating action tags
        run: |
          if grep -RInE '^[[:space:]]*-?[[:space:]]*uses:[[:space:]]*[^#]+@(main|master|v[0-9]+)([[:space:]]|$)' .github/workflows/; then
            echo "Floating action refs found. Pin to full SHA."
            exit 1
          fi
</code></pre>
<p>The check should run on pull requests and on protected branches. A pull request can pass this test and still contain a malicious commit, so combine it with code review, the allowlist, and the SHA validation process.</p>
<h2 id="heading-vet-third-party-actions">Vet Third-Party Actions</h2>
<p>Before adding a community action to the allowlist:</p>
<ol>
<li><p>Read the action's <code>action.yml</code> and entry script.</p>
</li>
<li><p>Check star history, maintainer reputation, and open security issues.</p>
</li>
<li><p>Pin SHA and fork to <code>my-org/action-name</code> if the action is critical but externally maintained.</p>
</li>
</ol>
<p>Also inspect the permissions available to the workflow. An action that can read repository secrets or write releases deserves more scrutiny than an action that only formats a file. Set the workflow's top-level permissions to the minimum required, then grant additional permissions to individual jobs only when needed.</p>
<h2 id="heading-how-to-verify-this-works">How to Verify This Works</h2>
<ol>
<li><p>Run the SHA validation script for every action you approve and expect all comparisons to pass.</p>
</li>
<li><p>Open a test pull request that changes an action to<code>@main</code>, and confirm the CI gate fails.</p>
</li>
<li><p>Add a trailing comment after a floating tag and confirm your parser or linter still rejects it.</p>
</li>
<li><p>Confirm organization policy blocks a disallowed action in a test workflow.</p>
</li>
<li><p>Confirm Dependabot opens an action update pull request and that the change receives normal review.</p>
</li>
<li><p>Review the workflow run permissions and verify that the action can't access credentials it doesn't need.</p>
</li>
</ol>
<h2 id="heading-when-this-breaks-down">When This Breaks Down</h2>
<ol>
<li><p><strong>Emergency patches:</strong> SHA pinning slows hotfixes. Dependabot plus on-call review is safer than temporarily using a floating tag.</p>
</li>
<li><p><strong>Composite actions in private repos:</strong> pin internal actions the same way as public ones.</p>
</li>
<li><p><strong>Reusable workflows:</strong> pin the reusable workflow ref to SHA as well as steps inside it.</p>
</li>
<li><p><strong>Annotated tags and mirrors:</strong> resolve annotated tags to their commit before recording a SHA, and validate internal mirrors through your own change-control process.</p>
</li>
<li><p><strong>False sense of safety:</strong> pinning without reading code still trusts the maintainer at pin time. Combine pinning with allowlists, least-privilege permissions, and review.</p>
</li>
</ol>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In this tutorial, you learned how to prevent poisoned CI dependencies by pinning GitHub Actions to reviewed commit SHAs, validating those references, allowlisting trusted actions, enabling Dependabot updates, and enforcing pins in pull request checks.</p>
<p>The result is a layered control: a reviewed immutable reference, a restricted set of permitted actions, automated update proposals, and a pull request check that catches regressions before merge.</p>
<h2 id="heading-references">References</h2>
<ul>
<li><p><a href="https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions">GitHub Actions, Security hardening</a></p>
</li>
<li><p><a href="https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot">GitHub, Dependabot version updates for Actions</a></p>
</li>
<li><p><a href="https://www.stepsecurity.io/blog/pinning-github-actions-for-enhanced-security">StepSecurity, Pin GitHub Actions</a></p>
</li>
<li><p><a href="https://github.com/ossf/scorecard/blob/main/docs/checks.md#pinned-dependencies">OpenSSF, Source compromise defenses</a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Catch Security Vulnerabilities in Code Before They Reach Your Pull Requests ]]>
                </title>
                <description>
                    <![CDATA[ Security reviews are most effective when developers receive feedback while the code is still fresh in their minds. Waiting until a pull request, CI build, or penetration test to find exposed credentia ]]>
                </description>
                <link>https://www.freecodecamp.org/news/catch-security-vulnerabilities-code-pull-requests/</link>
                <guid isPermaLink="false">6aa873c5c1f02b411d54db2f</guid>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ DevSecOps ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Git ]]>
                    </category>
                
                    <category>
                        <![CDATA[ SAST ]]>
                    </category>
                
                    <category>
                        <![CDATA[ software development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Umair Mirza ]]>
                </dc:creator>
                <pubDate>Mon, 14 Sep 2026 22:23:01 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/1443bfb4-581b-457b-afc3-7a85dd5fb3be.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Security reviews are most effective when developers receive feedback while the code is still fresh in their minds. Waiting until a pull request, CI build, or penetration test to find exposed credentials and insecure patterns creates unnecessary rework.</p>
<p>A lightweight way to shift security left is to run static application security testing (SAST) locally through Git pre-commit hooks.</p>
<p>In this guide, you'll install the DevSkim CLI, wire it into <code>pre-commit</code> so it lints staged files, add a dedicated secret scanner alongside it, verify the setup with a deliberate failure, and enforce the same checks in CI.</p>
<h3 id="heading-what-well-cover">What We'll Cover:</h3>
<ul>
<li><p><a href="#heading-why-pre-commit-security-scanning">Why Pre-Commit Security Scanning?</a></p>
</li>
<li><p><a href="#heading-what-is-devskim">What Is DevSkim?</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-create-a-pre-commit-configuration">Create a Pre-Commit Configuration</a></p>
</li>
<li><p><a href="#heading-install-the-git-hook">Install the Git Hook</a></p>
</li>
<li><p><a href="#heading-verify-the-setup-with-a-deliberate-failure">Verify the Setup With a Deliberate Failure</a></p>
</li>
<li><p><a href="#heading-example-catching-a-hardcoded-secret">Example: Catching a Hardcoded Secret</a></p>
</li>
<li><p><a href="#heading-keep-hooks-fast-and-focused">Keep Hooks Fast and Focused</a></p>
</li>
<li><p><a href="#heading-handling-false-positives">Handling False Positives</a></p>
</li>
<li><p><a href="#heading-add-security-checks-to-ci-too">Add Security Checks to CI Too</a></p>
</li>
<li><p><a href="#heading-practical-rollout-tips">Practical Rollout Tips</a></p>
</li>
<li><p><a href="#heading-final-thoughts">Final Thoughts</a></p>
</li>
</ul>
<h2 id="heading-why-pre-commit-security-scanning">Why Pre-Commit Security Scanning?</h2>
<p>Pre-commit hooks run automatically before Git creates a commit. They're useful for catching hardcoded passwords, tokens, and connection strings, insecure cryptographic patterns, weak input validation, risky file or process handling, and other language-specific security anti-patterns.</p>
<p>The goal isn't to replace CI security scanning or manual reviews. It's to give developers fast, actionable feedback at the earliest practical point.</p>
<p>A healthy model looks like this:</p>
<pre><code class="language-text">Developer workstation
  -&gt; Pre-commit security checks
  -&gt; Pull request review
  -&gt; CI/CD SAST and dependency scanning
  -&gt; Runtime monitoring and vulnerability management
</code></pre>
<h2 id="heading-what-is-devskim">What Is DevSkim?</h2>
<p>DevSkim is a security linter created by Microsoft. It ships as IDE extensions and a cross-platform CLI, and it flags insecure coding patterns using a configurable rule set.</p>
<p>It works well as a developer-facing guardrail because it scans files quickly and explains why a pattern may be risky. Its rules cover dangerous API usage, weak cryptography, insecure deserialization, weak TLS or certificate validation, and potential command injection risks.</p>
<p>One distinction matters before you rely on it: DevSkim is a security linter, not a secret scanner. It bundles a few generic credential rules, but those are regex patterns aimed at long, token-like values.</p>
<p>Teams typically pair DevSkim with a dedicated secret-scanning tool such as <a href="https://github.com/gitleaks/gitleaks">Gitleaks</a>, <a href="https://github.com/Yelp/detect-secrets">detect-secrets</a>, or <a href="https://github.com/trufflesecurity/trufflehog">TruffleHog</a>. Gitleaks and detect-secrets both publish official <code>pre-commit</code> hooks, so either slots into this workflow easily. This guide uses Gitleaks.</p>
<p>As with any security scanner, findings need context. A warning isn't automatically a vulnerability, but it's worth reviewing before the code moves downstream.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>You'll need Git, Python 3, and the .NET SDK, which DevSkim's CLI is distributed through.</p>
<p>Install <code>pre-commit</code>:</p>
<pre><code class="language-bash">pip install pre-commit
</code></pre>
<p>Install the DevSkim CLI as a .NET global tool:</p>
<pre><code class="language-bash">dotnet tool install --global Microsoft.CST.DevSkim.CLI
</code></pre>
<p>If you prefer not to install the .NET SDK, platform-specific binaries are available on the <a href="https://github.com/microsoft/DevSkim/releases">DevSkim releases page</a>.</p>
<p>Confirm both installations in a new terminal, so it picks up the updated <code>PATH</code>:</p>
<pre><code class="language-bash">pre-commit --version
devskim --version
</code></pre>
<p>For inline feedback while you type, also install the <a href="https://marketplace.visualstudio.com/items?itemName=MS-CST-E.vscode-devskim">DevSkim VS Code extension</a>. The CLI is what the Git hook uses.</p>
<h2 id="heading-create-a-pre-commit-configuration">Create a Pre-Commit Configuration</h2>
<p>DevSkim's CLI takes a single source path after <code>-I</code>, while <code>pre-commit</code> appends every staged filename to the command it runs. Passing <code>devskim analyze</code> directly therefore breaks as soon as a commit touches more than one file.</p>
<p>The simplest fix is a small wrapper that invokes DevSkim once per filename. Create <code>scripts/run-devskim.py</code> in your repository:</p>
<pre><code class="language-python">import subprocess
import sys

exit_code = 0

for filename in sys.argv[1:]:
    result = subprocess.run(["devskim", "analyze", "-I", filename])
    exit_code = exit_code or result.returncode

sys.exit(exit_code)
</code></pre>
<p>Now create <code>.pre-commit-config.yaml</code> at the root of your repository:</p>
<pre><code class="language-yaml">repos:
  - repo: local
    hooks:
      - id: devskim
        name: DevSkim security lint
        entry: python scripts/run-devskim.py
        language: system
        types_or: [python, javascript, typescript, json, yaml]
        pass_filenames: true

  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.30.1
    hooks:
      - id: gitleaks
</code></pre>
<p>The first hook runs DevSkim against the staged files that match the listed types. The second adds Gitleaks for the secret detection DevSkim isn't designed to handle.</p>
<p>If you would rather keep things simpler and scan the whole repository on every commit, drop the wrapper and use a single fixed path instead:</p>
<pre><code class="language-yaml">      - id: devskim
        name: DevSkim security lint
        entry: devskim analyze -I .
        language: system
        pass_filenames: false
</code></pre>
<p>That version is easier to reason about, but it gets slower as the repository grows. Staged-file scanning is the better default once a project has real history.</p>
<h2 id="heading-install-the-git-hook">Install the Git Hook</h2>
<p>Run this once from the repository root:</p>
<pre><code class="language-bash">pre-commit install
</code></pre>
<p>Now, every <code>git commit</code> triggers the configured checks.</p>
<p>You can run the hooks manually against the entire codebase:</p>
<pre><code class="language-bash">pre-commit run --all-files
</code></pre>
<p>That's particularly useful when introducing the tool to an existing project. Expect initial findings. Older repositories often contain patterns that predate current security standards.</p>
<h2 id="heading-verify-the-setup-with-a-deliberate-failure">Verify the Setup With a Deliberate Failure</h2>
<p>Before trusting the hook, you'll want to confirm that it actually blocks something. Create a file called <code>insecure-example.js</code> with a weak hashing call:</p>
<pre><code class="language-javascript">const crypto = require("crypto");

const hash = crypto.createHash("md5").update("password").digest("hex");
</code></pre>
<p>Stage it and attempt a commit:</p>
<pre><code class="language-bash">git add insecure-example.js
git commit -m "Test security hooks"
</code></pre>
<p>The commit should be rejected. <code>pre-commit</code> prints a status line per hook, followed by the failing hook's ID, its exit code, and the scanner's own findings:</p>
<pre><code class="language-text">DevSkim security lint....................................................Failed
- hook id: devskim
- exit code: 1

[DevSkim output listing the weak hash finding appears here]
</code></pre>
<p>DevSkim's finding text includes a rule ID, a severity label, and the file and line, and the exact formatting varies by version and ruleset. What matters is the non-zero exit code and the blocked commit.</p>
<p>If the hook passes unexpectedly, run the scan directly to isolate the problem:</p>
<pre><code class="language-bash">devskim analyze -I insecure-example.js
</code></pre>
<p>If that reports the finding but the hook does not, the issue is in your <code>.pre-commit-config.yaml</code> rather than in DevSkim.</p>
<h2 id="heading-example-catching-a-hardcoded-secret">Example: Catching a Hardcoded Secret</h2>
<p>Secrets are where the linter and the secret scanner divide. DevSkim's generic credential rules are regex-based and target long, token-like values, so a readable placeholder string will usually slip past them. A value shaped like a real key is a more realistic test:</p>
<pre><code class="language-javascript">const apiKey = "4f2a9c1e7b6d3a8f0c5e9b2d7a41c6";
</code></pre>
<p>Stage the change and run the hooks:</p>
<pre><code class="language-bash">git add config.js
pre-commit run
</code></pre>
<p>This is the case the secret scanner is built for. Gitleaks applies entropy and pattern checks tuned for credentials, so it's the hook you should expect to block a value like this, and its status line will report a failure and a non-zero exit code. Treat DevSkim's result here as a bonus rather than the control you rely on, which is exactly why the two tools sit side by side.</p>
<p>The fix is to move the value out of source control:</p>
<pre><code class="language-javascript">const apiKey = process.env.PAYMENT_API_KEY;
</code></pre>
<p>The real value should be stored in an approved secret manager, such as Azure Key Vault, GitHub Actions Secrets, or another managed platform.</p>
<p>This small shift matters. Removing a secret after it has been committed is harder, because Git history, clones, CI logs, and deployment systems may already contain it.</p>
<p>Once you have confirmed both hooks behave as expected, delete the test files. Never use a real credential to test a scanner.</p>
<h2 id="heading-keep-hooks-fast-and-focused">Keep Hooks Fast and Focused</h2>
<p>Developers will bypass slow hooks. A good local security scan should complete quickly and focus on high-confidence findings.</p>
<p>Start by scanning only staged or changed files locally, then reserve broader scans for CI. Block high-severity findings such as exposed secrets and critical insecure API usage first. Lower-confidence findings can be reported for review while the team tunes noisy rules and documents justified suppressions.</p>
<h2 id="heading-handling-false-positives">Handling False Positives</h2>
<p>False positives are expected in static analysis. The wrong response is disabling the scanner entirely. Verify whether a finding is exploitable and fix it when it represents real risk. When an exception is justified, use a narrowly scoped suppression, record the reason, and review it periodically.</p>
<p>Avoid broad exclusions such as skipping entire directories unless there's a clear technical reason. Broad exclusions tend to become blind spots.</p>
<h2 id="heading-add-security-checks-to-ci-too">Add Security Checks to CI Too</h2>
<p>Pre-commit hooks improve developer feedback, but they aren't enforcement. You can skip hooks with:</p>
<pre><code class="language-bash">git commit --no-verify
</code></pre>
<p>That's why the same or equivalent security checks should run in CI. Here's a GitHub Actions workflow that runs both tools on every pull request. Save it as <code>.github/workflows/security.yml</code>:</p>
<pre><code class="language-yaml">name: Security Checks

on:
  pull_request:
  push:
    branches: [main]

jobs:
  scan:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: "8.0.x"

      - name: Install DevSkim
        run: dotnet tool install --global Microsoft.CST.DevSkim.CLI

      - name: Run DevSkim
        run: devskim analyze -I . -f sarif -O devskim.sarif

      - name: Upload DevSkim results
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: devskim.sarif

      - name: Run Gitleaks
        uses: gitleaks/gitleaks-action@v2
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
</code></pre>
<p>A few details here are worth calling out. <code>fetch-depth: 0</code> gives Gitleaks the full history it needs to scan past commits rather than just the tip. The SARIF upload sends DevSkim findings to the repository's Security tab, so results are reviewable in GitHub instead of buried in build logs. Microsoft also publishes a <a href="https://github.com/microsoft/DevSkim-Action">DevSkim Action</a> if you prefer not to manage the CLI install yourself.</p>
<p>From there, use pre-commit for fast feedback on changed code, pull request pipelines for SAST, secret scanning, and dependency checks, main-branch builds for full scans and reporting, and release pipelines for gates on critical findings. This layered approach balances developer experience with governance.</p>
<h2 id="heading-practical-rollout-tips">Practical Rollout Tips</h2>
<p>Start with one or two repositories and baseline existing findings before enforcing new rules. Initially, block only high-confidence, high-impact issues, then share examples of findings and fixes with developers. Track recurring patterns to guide secure coding training, and measure adoption and time-to-remediation instead of focusing only on finding counts.</p>
<p>The aim is to help developers make secure choices by default, not create another tool that gets ignored.</p>
<h2 id="heading-final-thoughts">Final Thoughts</h2>
<p>Security tooling is most valuable when it appears at the moment a developer can act on it. Pre-commit hooks make secure coding feedback immediate, while CI provides the enforcement and broader coverage needed for production systems.</p>
<p>DevSkim is a useful lightweight entry point for teams building a shift-left security practice, and pairing it with a dedicated secret scanner covers the gap a linter is not built to fill. Start small, tune the rules, keep scans fast, and support the workflow with CI validation and secure coding guidance.</p>
<p>The best security control is often the one that prevents the issue from being committed in the first place.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How AI Is Changing Malware Detection: From Traditional Antivirus to Next-Gen Protection ]]>
                </title>
                <description>
                    <![CDATA[ Malware used to be simple to describe. A virus attached itself to a file, and antivirus software removed it. That world is gone. Today, a single attack can steal your passwords, lock up your photos, w ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-ai-is-changing-malware-detection/</link>
                <guid isPermaLink="false">6aa41cc6739dc5dd502bad21</guid>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Malware ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ai agents ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Manish Shivanandhan ]]>
                </dc:creator>
                <pubDate>Fri, 11 Sep 2026 15:22:46 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/891dc84b-49d3-4823-bf82-fc5acac84c43.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Malware used to be simple to describe. A virus attached itself to a file, and antivirus software removed it.</p>
<p>That world is gone. Today, a single attack can steal your passwords, lock up your photos, watch what you type, and hide inside software you trust.</p>
<p>The bigger problem is volume. The <a href="https://www.av-test.org/en/statistics/malware/">AV-TEST Institute records over 450,000 new malicious programs</a> every single day. No security team can review that many files by hand. So the job has moved to machines, and antivirus software's decision-making has changed with it.</p>
<p>In this article, we'll look at how signature scanning worked and why it started to fail. We'll also cover what machine learning adds, how behaviour tracking catches ransomware while it runs, how cloud threat data turns every device into a sensor, and where AI still gets things wrong.</p>
<h3 id="heading-what-well-cover">What We'll Cover:</h3>
<ul>
<li><p><a href="#heading-signature-scanning-worked-until-malware-learned-to-change">Signature Scanning Worked Until Malware Learned to Change</a></p>
</li>
<li><p><a href="#heading-why-todays-malware-is-so-hard-to-spot">Why Today's Malware is So Hard to Spot</a></p>
</li>
<li><p><a href="#heading-what-machine-learning-adds-to-the-picture">What Machine Learning Adds to the Picture</a></p>
</li>
<li><p><a href="#heading-watching-what-software-does-not-just-what-it-looks-like">Watching What Software Does, Not Just What it Looks Like</a></p>
</li>
<li><p><a href="#heading-stopping-ransomware-while-its-still-running">Stopping Ransomware While it's Still Running</a></p>
</li>
<li><p><a href="#heading-the-cloud-turns-every-device-into-a-sensor">The Cloud Turns Every Device into a Sensor</a></p>
</li>
<li><p><a href="#heading-catching-the-attack-before-malware-ever-lands">Catching the Attack Before Malware Ever Lands</a></p>
</li>
<li><p><a href="#heading-where-ai-still-gets-it-wrong">Where AI Still Gets it Wrong</a></p>
</li>
<li><p><a href="#heading-layers-beat-any-single-trick">Layers Beat Any Single Trick</a></p>
</li>
<li><p><a href="#heading-what-this-means-for-you">What This Means For You</a></p>
</li>
</ul>
<h2 id="heading-signature-scanning-worked-until-malware-learned-to-change">Signature Scanning Worked Until Malware Learned to Change</h2>
<p>Early antivirus software used signatures. A signature is a small pattern taken from a known bad file, a bit like a fingerprint.</p>
<p>Researchers found a piece of malware, pulled out its pattern, and added it to a database. Your antivirus downloaded that database and compared it against every file on your disk. A match meant the file was blocked.</p>
<p>This was fast, cheap, and easy to trust. It also had one big weakness.</p>
<p>Attackers figured out that they only had to change the file a little. A new name, some padding, a different way of packing the code, and the fingerprint no longer matched. The old signature was suddenly useless.</p>
<p>That created a gap. A new threat would spread for hours or days before anyone wrote a signature for it. Attackers now build thousands of tiny variations of the same program on purpose, making it a losing race to write one signature per version.</p>
<p>Signatures are still worth keeping. They catch known threats in milliseconds. They just can't be the only thing standing between you and an attack.</p>
<h2 id="heading-why-todays-malware-is-so-hard-to-spot">Why Today's Malware is So Hard to Spot</h2>
<p>Modern malware tries hard to look boring.</p>
<p>It may sit quiet for days before doing anything. It may arrive as a harmless-looking script and download the real payload later. Some of it never writes a file to disk at all, which is why Microsoft groups these as <a href="https://learn.microsoft.com/en-us/defender-endpoint/malware/fileless-threats">fileless threats</a>.</p>
<p>Worse, plenty of attacks use tools that are already on your computer. An attacker who gets access can run commands through <a href="https://attack.mitre.org/techniques/T1059/001/">PowerShell</a>, a normal Windows administration tool. The <a href="https://lolbas-project.github.io/">LOLBAS project</a> catalogues hundreds of trusted Windows programs that can be abused this way.</p>
<p>Nothing malicious is being installed here. A trusted tool is simply being used for the wrong reason. A file scanner has almost nothing to grab onto.</p>
<p>So the question security software asks has changed. It's no longer just "have I seen this file before?" It's "what is this program actually doing?"</p>
<h2 id="heading-what-machine-learning-adds-to-the-picture">What Machine Learning Adds to the Picture</h2>
<p>Machine learning doesn't give software a sixth sense. It gives it a way to make a judgment call from evidence.</p>
<p>A model is trained on huge sets of files, both safe and harmful. Over time, it learns which traits tend to show up in each group. It learns about file structure, how the code is packed, which system calls it makes, what it talks to over the network, and how it interacts with other programs.</p>
<p>When something new arrives, the model weighs those traits and estimates the risk. It never saw this exact file, but it has seen the shape of the problem before.</p>
<p>This matters most for variants. Attackers often rewrite the surface of their code and keep the guts the same. Signatures miss the family resemblance. A model trained on behaviour and structure often catches it.</p>
<h2 id="heading-watching-what-software-does-not-just-what-it-looks-like">Watching What Software Does, Not Just What it Looks Like</h2>
<p>The biggest shift in antivirus protection is the move from scanning files to watching actions.</p>
<p>Picture an unknown program starting up on a laptop. Within seconds, it opens hundreds of documents, rewrites each one, changes their file extensions, deletes the recovery copies, and calls out to a server nobody recognises.</p>
<p>There may be no signature for it anywhere. But that pattern is ransomware, and it's unmistakable.</p>
<p>Microsoft describes this approach in its documentation on <a href="https://learn.microsoft.com/en-us/defender-endpoint/behavioral-blocking-containment">behavioral blocking and containment</a>, where machine learning models score a chain of actions rather than a single file. Individual steps can look innocent. Read together, they tell a story.</p>
<p>The advantage is timing. The software doesn't need to have met this exact malware before. It only needs to notice the shape of the attack early enough to cut it off.</p>
<h2 id="heading-stopping-ransomware-while-its-still-running">Stopping Ransomware While it's Still Running</h2>
<p>Ransomware shows why this matters more than any other threat type.</p>
<p>Known ransomware families get caught by signatures without trouble. But a brand new variant will often slip straight past them. That's the whole point of building a new variant.</p>
<p>Behavior monitoring gives you a second chance. The software watches for rapid file changes across many folders, attempts to delete backups or shadow copies, and processes trying to shut off security tools. Federal guidance on the <a href="https://www.cisa.gov/stopransomware">CISA StopRansomware hub</a> leans on the same signals, alongside offline backups you can actually restore from.</p>
<p>Once enough warning signs stack up, the software can kill the process or pull the device off the network. Even a partial save matters here. Stopping an attack after fifty encrypted files is a very different day than stopping it after fifty thousand.</p>
<p>Products aimed at everyday users are moving the same way. The <a href="https://nordvpn.com/next-gen-antivirus/">next-gen antivirus from NordVPN</a> blocks malicious downloads and scam pages before they reach the device, which shows how consumer tools have widened past plain file matching.</p>
<h2 id="heading-the-cloud-turns-every-device-into-a-sensor">The Cloud Turns Every Device into a Sensor</h2>
<p>Everything so far has been about <em>what</em> security software examines: files first, then behaviour. The other big change is <em>where</em> that examination happens.</p>
<p>Signature scanning ran start to finish on your machine. Your antivirus pulled down a database, compared files against it locally, and that was the entire decision.</p>
<p>Modern protection splits the work instead. Cheap checks stay on the device so they're instant, and the harder calls get handed to the vendor's cloud, which can see far more than any one laptop ever will.</p>
<p>Here's the actual sequence: First, the agent installed on your device records security-relevant events: process launches, parent-child process relationships, registry edits, outbound connections, file hashes. When it meets something it can't classify on its own, it sends metadata about it (typically the hash, the file's structural traits, and the surrounding activity, rather than the whole document) to the vendor's backend over an encrypted channel.</p>
<p>Microsoft documents this handoff for Defender in its notes on <a href="https://learn.microsoft.com/en-us/defender-endpoint/cloud-protection-microsoft-defender-antivirus">cloud protection</a>, where the local client queries the cloud service for a verdict and briefly holds the file while it waits for an answer.</p>
<p>The backend does two jobs at once. Automated systems compare the submission against what millions of other devices have reported and score it with models far too large to ship to a laptop.</p>
<p>If the picture is clear, a verdict comes back in under a second with no human involved. If it isn't, the case escalates to the vendor's threat research and security operations teams, the analysts employed specifically to hunt for clusters like this.</p>
<p>That's what makes the "few thousand devices" signal worth something. If the same unfamiliar binary, or the same odd process chain, appears across thousands of unrelated organisations inside an hour, no single one of them would notice. The backend sees the cluster immediately and flags it for a human to open up. Analysts pull samples, detonate them in a sandbox, confirm what the code does, and write a detection for it.</p>
<p>Then the loop closes. Confirmed cases become labelled training data, which is exactly what the next generation of models needs. The output goes out in two speeds: new indicators like hashes, domains and behavioural rules reach every protected device within minutes, while retrained models follow on a slower cycle of days or weeks.</p>
<p>Either way, the person targeted next is protected by what your device reported, and nobody had to wait for the next big database download.</p>
<h2 id="heading-catching-the-attack-before-malware-ever-lands">Catching the Attack Before Malware Ever Lands</h2>
<p>Not every threat arrives as a program. Most start with a message.</p>
<p>Phishing is still the front door. The APWG counted <a href="https://apwg.org/trendsreports/">more than one million phishing attacks in a single quarter</a>, and the fake pages are getting harder to eyeball. Attackers copy bank branding, login screens, and delivery notices closely enough to fool careful people.</p>
<p>AI helps by checking the things humans skip: how old the domain is, whether the link redirects somewhere odd, and whether the page matches a known scam kit.</p>
<p>Blocking a page like that stops the attack one step earlier. No download, no file to scan, and no cleanup.</p>
<h2 id="heading-where-ai-still-gets-it-wrong">Where AI Still Gets it Wrong</h2>
<p>AI brings real problems along with the benefits, and it's worth being clear about them.</p>
<p>False positives are the everyday one. Unusual isn't the same as malicious, and software that blocks a legitimate app because it looked odd trains people to switch protection off.</p>
<p>Speed is another. All this analysis has to happen without making the machine feel slow.</p>
<p>Then there's the arms race. Attackers study these models too. NIST's report on <a href="https://csrc.nist.gov/pubs/ai/100/2/e2025/final">adversarial machine learning</a> lays out how models get poisoned during training or fooled at the moment of decision. A model is a target, not a fortress.</p>
<p>Privacy deserves a mention as well. Cloud analysis means some information about your files and connections leaves your device. It's fair to ask any vendor what they collect and how long they keep it.</p>
<h2 id="heading-layers-beat-any-single-trick">Layers Beat Any Single Trick</h2>
<p>None of this replaces what came before. The strongest setups stack methods on purpose.</p>
<p>Signatures handle known malware instantly. Reputation checks block bad sites and untrusted programs. Behaviour monitoring catches suspicious activity as it happens. Machine learning fills the gap for threats nobody has named yet.</p>
<p>The <a href="https://attack.mitre.org/">MITRE ATT&amp;CK framework</a> is useful here because it maps out attacker techniques so teams can see which layer covers which step.</p>
<p>Layers also give the software context. A file with a clean history that starts acting strangely deserves a closer look. A file already known to be malware doesn't need any analysis at all.</p>
<h2 id="heading-what-this-means-for-you">What This Means For You</h2>
<p>Antivirus software has moved a long way from matching files against a list.</p>
<p>Signatures still earn their place, but they only answer one question. AI and behaviour monitoring answer a better one: what is this software doing right now, and does it make sense?</p>
<p>The practical takeaway is short. Good protection today is less about recognizing bad files and more about noticing bad behavior quickly. If you're choosing security software, ask whether it watches activity or only scans files, and check whether it blocks dangerous sites and downloads before they arrive.</p>
<p>Attacks keep getting faster and more automated. The defenses have to work the same way, and that's the real reason AI ended up at the centre of malware detection.</p>
<p>Hope you enjoyed this article. You can <a href="https://linkedin.com/in/manishmshiva">connect with me on LinkedIn</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Use Lovable Responsibly ]]>
                </title>
                <description>
                    <![CDATA[ Building an app used to feel like assembling furniture without instructions, while missing half the screws. Today, AI-powered tools such as Lovable can help you turn an idea into a working web applica ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-use-lovable-responsibly/</link>
                <guid isPermaLink="false">6aa2d601395968ffa3528970</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Artificial Intelligence ]]>
                    </category>
                
                    <category>
                        <![CDATA[ software development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ lovable ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Eva J Patel ]]>
                </dc:creator>
                <pubDate>Thu, 10 Sep 2026 16:08:33 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/985f1786-0356-43fe-94c5-697f1938b118.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Building an app used to feel like assembling furniture without instructions, while missing half the screws. Today, AI-powered tools such as Lovable can help you turn an idea into a working web application by describing what you want in plain language.</p>
<p>That's exciting. It's also a responsibility.</p>
<p>Lovable can help you move quickly, experiment with ideas, and create useful software. But speed shouldn't replace careful thinking. A generated app can contain security problems, confusing user experiences, inaccurate information, or code that works in a demonstration but falls apart in real life.</p>
<p>In this guide, you'll learn practical ways to use Lovable while keeping security, privacy, accessibility, and user safety in mind. We'll cover how to write clearer prompts, protect sensitive information, test authentication and authorization, validate user input, work with realistic test data, review AI-generated code, and decide when an application is ready to share.</p>
<p>By the end, you'll have a simple workflow for building with Lovable more responsibly without giving up the speed and creativity that make AI-powered development useful.</p>
<h2 id="heading-what-well-cover">What We'll Cover:</h2>
<ul>
<li><p><a href="#heading-what-is-lovable">What Is Lovable?</a></p>
</li>
<li><p><a href="#heading-why-responsible-use-matters">Why Responsible Use Matters</a></p>
</li>
<li><p><a href="#heading-start-with-a-clear-and-straightforward-idea">Start With a Clear and Straightforward Idea</a></p>
</li>
<li><p><a href="#heading-do-not-enter-sensitive-information-unnecessarily">Do Not Enter Sensitive Information Unnecessarily</a></p>
</li>
<li><p><a href="#heading-protect-secrets-with-environment-variables">Protect Secrets With Environment Variables</a></p>
</li>
<li><p><a href="#heading-understand-what-your-app-does">Understand What Your App Does</a></p>
</li>
<li><p><a href="#heading-build-security-into-your-prompts">Build Security Into Your Prompts</a></p>
</li>
<li><p><a href="#heading-test-authentication-and-authorization-separately">Test Authentication and Authorization Separately</a></p>
</li>
<li><p><a href="#heading-validate-all-user-input">Validate All User Input</a></p>
</li>
<li><p><a href="#heading-be-careful-with-generated-dependencies">Be Careful With Generated Dependencies</a></p>
</li>
<li><p><a href="#heading-design-for-accessibility">Design for Accessibility</a></p>
</li>
<li><p><a href="#heading-avoid-dark-patterns">Avoid Dark Patterns</a></p>
</li>
<li><p><a href="#heading-handle-errors-effectively">Handle Errors Effectively</a></p>
</li>
<li><p><a href="#heading-be-honest-about-ai-generated-features">Be Honest About AI-Generated Features</a></p>
</li>
<li><p><a href="#heading-protect-personal-data">Protect Personal Data</a></p>
</li>
<li><p><a href="#heading-respect-copyright-and-ownership">Respect Copyright and Ownership</a></p>
</li>
<li><p><a href="#heading-test-with-realistic-but-fake-data">Test With Realistic but Fake Data</a></p>
</li>
<li><p><a href="#heading-test-before-you-share-the-app">Test Before You Share the App</a></p>
</li>
<li><p><a href="#heading-ask-lovable-to-review-its-own-work">Ask Lovable to Review Its Own Work</a></p>
</li>
<li><p><a href="#heading-learn-from-the-generated-code">Learn From the Generated Code</a></p>
</li>
<li><p><a href="#heading-use-lovable-for-prototyping-without-pretending-it-is-production-ready">Use Lovable for Prototyping Without Pretending It Is Production-Ready</a></p>
</li>
<li><p><a href="#heading-create-a-simple-responsible-development-workflow">Create a Simple Responsible Development Workflow</a></p>
</li>
<li><p><a href="#heading-a-responsible-prompt-template">A Responsible Prompt Template</a></p>
</li>
<li><p><a href="#heading-the-golden-rule-of-ai-app-building">The Golden Rule of AI App Building</a></p>
</li>
<li><p><a href="#heading-final-thoughts">Final Thoughts</a></p>
</li>
</ul>
<h2 id="heading-what-is-lovable">What Is Lovable?</h2>
<p>Lovable is an AI-powered app-building platform that allows you to describe an application using natural language. Instead of writing every line of code manually, you can explain what you want and let the tool generate parts of the interface, functionality, and application structure.</p>
<p>For example, you might write:</p>
<pre><code class="language-text">Create a task manager with user accounts, a dashboard, task categories, due dates, and a button for marking tasks as complete.
</code></pre>
<p>Lovable may then generate a starting point that you can review, test, and improve.</p>
<p>The key phrase is “starting point.” AI-generated software isn't automatically finished software. Think of Lovable as a very fast coding partner that needs clear instructions, thoughtful reviews, and occasional reminders not to put a banana-shaped button in the middle of your login form.</p>
<h2 id="heading-why-responsible-use-matters">Why Responsible Use Matters</h2>
<p>AI app builders make software development more accessible, but accessibility comes with responsibility. When you create an app, you're making decisions that can affect real people.</p>
<p>Your application might collect names, email addresses, messages, payment details, health information, or location data. It might make recommendations, display important information, or control access to something valuable.</p>
<p>A small mistake can create large problems.</p>
<p>Responsible development helps you:</p>
<ul>
<li><p>Protect user information</p>
</li>
<li><p>Reduce security risks</p>
</li>
<li><p>Avoid misleading users</p>
</li>
<li><p>Create accessible experiences</p>
</li>
<li><p>Respect copyright and ownership</p>
</li>
<li><p>Test your application before sharing it</p>
</li>
<li><p>Understand the code and services your app uses</p>
</li>
<li><p>Make decisions that are fair and explainable</p>
</li>
</ul>
<p>You don't need to be a security expert to use Lovable responsibly. But you do need to slow down long enough to ask good questions.</p>
<h2 id="heading-start-with-a-clear-and-straightforward-idea">Start With a Clear and Straightforward Idea</h2>
<p>Before asking Lovable to build an app, describe the problem you want to solve.</p>
<p>A vague prompt such as this:</p>
<pre><code class="language-plaintext">Build a cool productivity app.
</code></pre>
<p>leaves a lot of room for confusion.</p>
<p>A clearer prompt might look like this:</p>
<pre><code class="language-plaintext">Build a simple productivity app for students. Users should be able to create tasks, assign a due date, mark tasks as complete, and filter tasks by status. Use plain language, a calm color palette, and a layout that works well on phones and desktop screens.
</code></pre>
<p>A strong prompt usually explains:</p>
<ul>
<li><p>Who the app is for</p>
</li>
<li><p>What problem it solves</p>
</li>
<li><p>What users should be able to do</p>
</li>
<li><p>What information the app stores</p>
</li>
<li><p>What the interface should feel like</p>
</li>
<li><p>What the app should not do</p>
</li>
<li><p>What platform or screen sizes it should support</p>
</li>
</ul>
<p>Clear instructions make it easier to review the result. They also reduce the chance that the AI invents unnecessary features that make your project more complicated than a group project with twelve shared spreadsheets.</p>
<h2 id="heading-dont-enter-sensitive-information-unnecessarily">Don't Enter Sensitive Information Unnecessarily</h2>
<p>When working with an AI-powered development tool, avoid including sensitive information in prompts unless it's genuinely necessary and handled through an appropriate process.</p>
<p>Don't paste in:</p>
<ul>
<li><p>Passwords</p>
</li>
<li><p>Private API keys</p>
</li>
<li><p>Authentication tokens</p>
</li>
<li><p>Credit card numbers</p>
</li>
<li><p>Personal identification numbers</p>
</li>
<li><p>Private customer records</p>
</li>
<li><p>Confidential business documents</p>
</li>
<li><p>Unreleased product details</p>
</li>
<li><p>Medical records</p>
</li>
<li><p>Private conversations</p>
</li>
</ul>
<p>Use placeholders instead:</p>
<pre><code class="language-plaintext">Use a placeholder for the payment provider API key.
</code></pre>
<p>Or:</p>
<pre><code class="language-plaintext">Connect to an email service using an environment variable named EMAIL\_API\_KEY. Do not hardcode the key in the source code.
</code></pre>
<p>A placeholder keeps your project easier to share, review, and maintain. It also prevents the classic “I accidentally published a secret to the internet” plot twist.</p>
<h2 id="heading-protect-secrets-with-environment-variables">Protect Secrets With Environment Variables</h2>
<p>Secrets shouldn't be placed directly in frontend code or committed to a public repository.</p>
<p>A safer pattern is to use environment variables:</p>
<pre><code class="language-javascript">const apiKey = process.env.API\_KEY;
</code></pre>
<p>For a client-side application, be especially careful. Environment variables used in browser code may be visible to users. A secret that must remain private should usually be used on a secure server or through a protected backend service.</p>
<p>Never use this pattern:</p>
<pre><code class="language-javascript">const apiKey = "your-real-secret-key";
</code></pre>
<p>Use a placeholder during development:</p>
<pre><code class="language-javascript">const apiKey = process.env.API\_KEY || "";
</code></pre>
<p>Then configure the real value through the appropriate secret-management system for your hosting platform.</p>
<p>Before deploying, search your project for common secret patterns such as:</p>
<pre><code class="language-plaintext">API\_KEYSECRETTOKENPASSWORDPRIVATE\_KEY
</code></pre>
<p>Finding a suspicious value doesn't always mean it's a secret, but it's worth checking.</p>
<h2 id="heading-understand-what-your-app-does">Understand What Your App Does</h2>
<p>Don't publish an application that you can't explain at a basic level.</p>
<p>You should know:</p>
<ul>
<li><p>What data the app collects</p>
</li>
<li><p>Where that data is stored</p>
</li>
<li><p>Which external services receive the data</p>
</li>
<li><p>Who can view or modify the data</p>
</li>
<li><p>How users delete their accounts or information</p>
</li>
<li><p>Which parts of the app require authentication</p>
</li>
<li><p>What happens when a request fails</p>
</li>
<li><p>What happens when a user enters unexpected input</p>
</li>
</ul>
<p>You don't need to understand every line immediately. But you should understand the major building blocks.</p>
<p>If Lovable generates code that you don't understand, ask it to explain a specific section:</p>
<pre><code class="language-plaintext">Explain how user authentication works in this project. Identify where sessions are created, how access is checked, and what could go wrong if authentication is misconfigured.
</code></pre>
<p>You can also ask:</p>
<pre><code class="language-plaintext">List all external services used by this application and explain what data each service receives.
</code></pre>
<p>Explanations are useful, but they aren't proof that the code is safe. Treat them as a map, not a magical safety certificate.</p>
<h2 id="heading-build-security-into-your-prompts">Build Security Into Your Prompts</h2>
<p>Security should be part of the original request, not an emergency patch added after someone discovers that every user can view every account.</p>
<p>Include security requirements in your prompts:</p>
<pre><code class="language-plaintext">Only authenticated users should be able to access the dashboard. Users must only be able to view and edit their own tasks. Validate all form inputs, display safe error messages, and never expose secrets in frontend code.
</code></pre>
<p>For an administrative area, you might write:</p>
<pre><code class="language-plaintext">Create an admin section that is available only to users with an admin role. Check authorization on the server for every admin action instead of relying only on hiding buttons in the interface.
</code></pre>
<p>For user-generated content:</p>
<pre><code class="language-plaintext">Allow users to submit comments, but sanitize and safely render the content to reduce cross-site scripting risks. Limit comment length and reject empty submissions.
</code></pre>
<p>Detailed prompts help the generated application start from better assumptions.</p>
<h2 id="heading-test-authentication-and-authorization-separately">Test Authentication and Authorization Separately</h2>
<p>Authentication answers the question, “Who are you?”, while authorization answers the question, “What are you allowed to do?”</p>
<p>These are different.</p>
<p>A user may be successfully logged in but still not be allowed to view another user’s private records. A responsible application checks both.</p>
<p>Test cases should include:</p>
<ol>
<li><p>A logged-out visitor tries to open a private page.</p>
</li>
<li><p>A regular user tries to open an administrator page.</p>
</li>
<li><p>A user tries to access another user's record by changing an identifier in the URL.</p>
</li>
<li><p>A user submits a request without the required session information</p>
</li>
<li><p>A user logs out and them presses the browser's back button.</p>
</li>
</ol>
<p>Don't rely only on hiding navigation links. A hidden button isn't a security system. If a user can still call a backend endpoint directly, the application may be vulnerable.</p>
<p>For example, suppose your application has a page at /admin that should only be available to administrators. You could test authentication and authorization separately like this:</p>
<h4 id="heading-authentication-test">Authentication Test</h4>
<p>Log out of the application and then try to open <code>/admin</code> directly. The application should redirect you to the login page or return an appropriate unauthorized response.</p>
<p>Then log in with a valid account and confirm that the application recognizes the authenticated session.</p>
<h4 id="heading-authorization-test">Authorization Test</h4>
<p>Log in with a normal user account that doesn't have an admin role. Then try to open <code>/admin</code> directly instead of using the navigation menu. The application should deny access.</p>
<p>Try the same test against the backend endpoint used by an admin action. Confirm that the server also rejects the request.</p>
<p>You can also test whether changing an identifier in a URL or request allows one user to access another user's information. The important part is to verify the behavior from the user's perspective and, where possible, confirm that the server is enforcing the permission rather than simply hiding parts of the interface.</p>
<h2 id="heading-validate-all-user-input">Validate All User Input</h2>
<p>Users will enter unexpected information. Sometimes this happens by accident. Sometimes it happens because users are testing the boundaries of your application. Occasionally, it happens because someone has decided that a username should be 4,000 characters long and contain seventeen emojis.</p>
<p>Validate input on the client for a better user experience and on the server for security.</p>
<p>Examples of validation include:</p>
<ul>
<li><p>Required fields</p>
</li>
<li><p>Maximum and minimum lengths</p>
</li>
<li><p>Valid email formats</p>
</li>
<li><p>Allowed file types</p>
</li>
<li><p>Maximum file sizes</p>
</li>
<li><p>Valid dates</p>
</li>
<li><p>Acceptable numeric ranges</p>
</li>
<li><p>Safe content handling</p>
</li>
</ul>
<p>A frontend check might look like this:</p>
<pre><code class="language-javascript">if (username.trim().length &lt; 3) {
     showError("Username must be at least 3 characters long.");
     return;
}                
</code></pre>
<p>But don't assume that frontend validation is enough. A user can bypass browser checks by sending requests directly to your backend.</p>
<p>The server should validate the data again before storing or processing it.</p>
<p>Server-side validation means treating everything received from the browser as untrusted input. The server should check that the submitted data has the expected type, format, length, and range before using it. It should also reject unexpected fields or values when appropriate.</p>
<p>For example, if an API accepts a username and age, the server could verify that the username is a non-empty string within the allowed length and that the age is a number within the application's acceptable range. If the request fails validation, the server should reject it rather than storing or processing the invalid data.</p>
<p>You can ask Lovable to help create these checks and generate test cases:</p>
<ul>
<li><p>Add server-side validation for every field in this form.</p>
</li>
<li><p>Reject missing, incorrectly formatted, oversized, or out-of-range values before they're stored or processed.</p>
</li>
<li><p>Then create tests for valid input, missing fields, invalid formats, boundary values, and unexpected input.</p>
</li>
</ul>
<p>AI-generated tests can be useful, but don't rely on them as the only verification. Run the tests yourself and manually try important edge cases as well. The goal is to use AI to speed up the work while keeping human judgment involved in checking whether the validation actually protects the application.</p>
<h2 id="heading-be-careful-with-generated-dependencies">Be Careful With Generated Dependencies</h2>
<p>AI-generated projects may use libraries, packages, plugins, and external services. These tools can be helpful, but each dependency adds another piece to understand and maintain.</p>
<p>Ask Lovable:</p>
<pre><code class="language-plaintext">List the main packages used in this project and explain why each one is needed.
</code></pre>
<p>You can also ask:</p>
<pre><code class="language-plaintext">Identify dependencies that are unnecessary for the current features and suggest a simpler alternative.
</code></pre>
<p>Fewer dependencies can mean:</p>
<ul>
<li><p>Less code to maintain</p>
</li>
<li><p>Fewer security updates</p>
</li>
<li><p>Smaller application size</p>
</li>
<li><p>Fewer compatibility problems</p>
</li>
<li><p>Easier debugging</p>
</li>
</ul>
<p>You don't need to remove every package. Just avoid collecting dependencies like digital souvenirs.</p>
<h2 id="heading-design-for-accessibility">Design for Accessibility</h2>
<p>An application isn't truly successful if many people can't use it.</p>
<p>Ask Lovable to include accessibility from the beginning:</p>
<pre><code class="language-plaintext">Make the interface accessible. Use semantic HTML, keyboard navigation, visible focus states, descriptive labels, sufficient color contrast, and accessible error messages.
</code></pre>
<p>Check whether:</p>
<ul>
<li><p>Buttons have clear names</p>
</li>
<li><p>Form inputs have labels</p>
</li>
<li><p>Keyboard users can reach every interactive element</p>
</li>
<li><p>Focus indicators are visible</p>
</li>
<li><p>Text has enough contrast</p>
</li>
<li><p>Images have useful alternative text</p>
</li>
<li><p>Error messages explain how to fix a problem</p>
</li>
<li><p>The layout works at different screen sizes</p>
</li>
<li><p>Content remains usable when text is enlarged</p>
</li>
</ul>
<p>Avoid using color as the only way to communicate meaning. For example, don't show errors only with a red border. Add text such as:</p>
<pre><code class="language-plaintext">Email address is required.
</code></pre>
<p>Accessibility isn't just a compliance task. It usually makes the application easier for everyone to use.</p>
<h2 id="heading-avoid-dark-patterns">Avoid Dark Patterns</h2>
<p>A responsible app should help users make informed choices. It shouldn't trick them into doing something they didn't intend.</p>
<p>Avoid:</p>
<ul>
<li><p>Preselected marketing consent</p>
</li>
<li><p>Hidden cancellation links</p>
</li>
<li><p>Confusing double negatives</p>
</li>
<li><p>Misleading buttons</p>
</li>
<li><p>Fake countdown timers</p>
</li>
<li><p>Notifications that look like system warnings</p>
</li>
<li><p>Subscriptions that are easy to start but difficult to stop</p>
</li>
<li><p>Important information hidden in tiny text</p>
</li>
</ul>
<p>Use clear labels:</p>
<pre><code class="language-plaintext">Delete account
</code></pre>
<p>is better than:</p>
<pre><code class="language-plaintext">Continue
</code></pre>
<p>when the action permanently deletes an account.</p>
<p>For destructive actions, provide a confirmation step that clearly explains what will happen:</p>
<pre><code class="language-plaintext">This will permanently delete your account and all saved tasks. This action cannot be undone.
</code></pre>
<p>Good design respects the user’s ability to choose.</p>
<h2 id="heading-handle-errors-effectively">Handle Errors Effectively</h2>
<p>Every application experiences errors. Networks fail. Services go offline. Users close tabs at inconvenient moments. Servers occasionally decide to take an unscheduled vacation.</p>
<p>Don't display vague or misleading messages such as:</p>
<pre><code class="language-plaintext">Something went wrong.
</code></pre>
<p>when you can provide useful guidance.</p>
<p>Better:</p>
<pre><code class="language-plaintext">We could not save your task because the connection was interrupted. Check your internet connection and try again.
</code></pre>
<p>For developers, log enough information to investigate the problem without exposing sensitive data:</p>
<pre><code class="language-javascript">try {  await saveTask(task);} catch (error) {  console.error("Task save failed", {    operation: "create\_task",    message: error.message  });
  showError("Your task could not be saved. Please try again.");}
</code></pre>
<p>Avoid sending passwords, tokens, private messages, or personal records into logs.</p>
<h2 id="heading-be-honest-about-ai-generated-features">Be Honest About AI-Generated Features</h2>
<p>If your application uses AI to generate text, recommendations, summaries, images, or decisions, users should understand that the output may be wrong.</p>
<p>Use clear language:</p>
<pre><code class="language-plaintext">This summary was generated automatically and may contain mistakes. Review it before sharing.
</code></pre>
<p>Avoid presenting AI-generated information as guaranteed fact, especially in areas such as:</p>
<ul>
<li><p>Health</p>
</li>
<li><p>Finance</p>
</li>
<li><p>Education</p>
</li>
<li><p>Employment</p>
</li>
<li><p>Legal information</p>
</li>
<li><p>Safety</p>
</li>
<li><p>Personal identity</p>
</li>
<li><p>News and public information</p>
</li>
</ul>
<p>Give users ways to correct, reject, or report problematic output. If an AI feature affects important decisions, provide human review whenever possible.</p>
<h2 id="heading-protect-personal-data">Protect Personal Data</h2>
<p>Collect only the information your app actually needs.</p>
<p>If a task manager only needs an email address for account recovery, it probably doesn't need a user’s home address, phone number, favorite color, and childhood nickname.</p>
<p>Before adding a data field, ask:</p>
<pre><code class="language-plaintext">Why do we need this information?
</code></pre>
<p>Then ask:</p>
<pre><code class="language-plaintext">What could happen if this information were exposed?
</code></pre>
<p>Good data practices include:</p>
<ul>
<li><p>Collecting less information</p>
</li>
<li><p>Explaining why information is needed</p>
</li>
<li><p>Restricting access</p>
</li>
<li><p>Deleting information when it's no longer necessary</p>
</li>
<li><p>Avoiding unnecessary analytics</p>
</li>
<li><p>Protecting data during transmission and storage</p>
</li>
<li><p>Giving users meaningful control over their information</p>
</li>
</ul>
<p>Data isn't free just because a form field is free to add.</p>
<h2 id="heading-respect-copyright-and-ownership">Respect Copyright and Ownership</h2>
<p>Don't ask Lovable to copy an existing product exactly, reproduce copyrighted artwork, or imitate a brand in a way that could confuse users.</p>
<p>Instead, describe the qualities you want:</p>
<pre><code class="language-plaintext">Create a clean project-management interface with a left sidebar, clear status labels, and a spacious layout. Use original styling and avoid copying any specific company's branding.
</code></pre>
<p>Be careful with:</p>
<ul>
<li><p>Images</p>
</li>
<li><p>Logos</p>
</li>
<li><p>Icons</p>
</li>
<li><p>Fonts</p>
</li>
<li><p>Code snippets</p>
</li>
<li><p>Written content</p>
</li>
<li><p>Product names</p>
</li>
<li><p>Brand colors</p>
</li>
<li><p>User-generated material</p>
</li>
</ul>
<p>Use assets that you created, licensed, or are allowed to use. When in doubt, choose an original design.</p>
<h2 id="heading-test-with-realistic-but-fake-data">Test With Realistic but Fake Data</h2>
<p>Use fictional data during development:</p>
<pre><code class="language-plaintext">Name: Jordan 
ExampleEmail: jordan@example.test
testOrder ID: TEST-1001
</code></pre>
<p>Don't use real customer records just because they're convenient.</p>
<p>Create test cases for:</p>
<ul>
<li><p>Empty states</p>
</li>
<li><p>Long names</p>
</li>
<li><p>Very long text</p>
</li>
<li><p>Invalid email addresses</p>
</li>
<li><p>Duplicate records</p>
</li>
<li><p>Missing images</p>
</li>
<li><p>Slow connections</p>
</li>
<li><p>Failed requests</p>
</li>
<li><p>Expired sessions</p>
</li>
<li><p>Multiple users</p>
</li>
<li><p>Different screen sizes</p>
</li>
<li><p>Keyboard-only navigation</p>
</li>
</ul>
<p>Fake data helps you test realistic behavior without exposing real people’s information.</p>
<p>You can create fake data yourself by using clearly fictional names, addresses, email addresses, identifiers, and other values that can't be mistaken for real customer information. For larger datasets, you can also <a href="https://www.freecodecamp.org/news/how-to-fine-tune-easyocr-with-a-synthetic-dataset/#heading-how-to-generate-your-synthetic-dataset">use a reputable fake-data generator</a> or ask Lovable to create a dataset specifically for testing.</p>
<p>For example, you could ask:</p>
<pre><code class="language-plaintext">Create 100 fictional user records for testing. Use clearly fake names and email addresses under `example.test`. Include different account types, missing optional fields, long names, and other edge cases. Do not use real people's information.
</code></pre>
<p>Review generated data before using it, especially if you obtain it from an external source. Avoid datasets containing real personal information unless you have a legitimate reason, appropriate authorization, and proper safeguards. When possible, use synthetic data designed specifically for testing so that realistic application behavior can be tested without exposing real people's information.</p>
<h2 id="heading-test-before-you-share-the-app">Test Before You Share the App</h2>
<p>Before showing your project to others, follow a basic release checklist.</p>
<pre><code class="language-plaintext">1. The app works on mobile and desktop screens.
2. Forms validate input correctly.
3. Authentication behaves as expected.
4. Users can't access data belonging to other users.
5. Secrets aren't included in frontend code.
6. Error messages are clear and safe.
7. Keyboard navigation works.
8. Important buttons have clear labels.
9. Empty states are understandable.
10. Loading states are visible.
11. Destructive actions require confirmation.
12. Test data doesn't contain real personal information.
13. External services are configured correctly.
14. The production environment uses secure settings.
15. The app has been tested after the final changes.
</code></pre>
<p>A checklist may feel less exciting than clicking a shiny “Publish” button, but it's much more exciting than explaining to users why the app deleted everything.</p>
<h2 id="heading-ask-lovable-to-review-its-own-work">Ask Lovable to Review Its Own Work</h2>
<p>AI tools can help with review tasks when given specific instructions.</p>
<p>Try prompts such as:</p>
<pre><code class="language-plaintext">Review this application for authentication and authorization problems. Identify any route, database query, or API endpoint that may expose data to the wrong user.
</code></pre>
<pre><code class="language-plaintext">Review the forms for missing validation, unclear error messages, and accessibility problems.
</code></pre>
<pre><code class="language-plaintext">Review the project for hardcoded secrets, unsafe logging, and sensitive information that might appear in the browser.
</code></pre>
<pre><code class="language-plaintext">Review the application for mobile layout problems and explain the changes you recommend.
</code></pre>
<p>Don't accept the review blindly. Compare the suggestions with your own testing and, for serious applications, get help from an experienced developer or security professional.</p>
<h2 id="heading-learn-from-the-generated-code">Learn From the Generated Code</h2>
<p>Using Lovable responsibly doesn't mean avoiding AI-generated code. It means using the tool as an opportunity to learn.</p>
<p>When you receive a result, ask:</p>
<pre><code class="language-plaintext">Explain this function in beginner-friendly language.
</code></pre>
<pre><code class="language-plaintext">Show me a simpler version of this code.
</code></pre>
<pre><code class="language-plaintext">What assumptions does this implementation make?
</code></pre>
<pre><code class="language-plaintext">What are the possible failure cases?
</code></pre>
<pre><code class="language-plaintext">How would this code behave with two users at the same time?
</code></pre>
<p>Try changing one small part manually. Read the error messages. Compare the before-and-after versions. Over time, the generated code will become less mysterious.</p>
<p>The goal isn't to memorize every programming concept immediately. The goal is to become confident enough to ask better questions and recognize risky answers.</p>
<h2 id="heading-use-lovable-for-prototyping-without-pretending-its-production-ready">Use Lovable for Prototyping Without Pretending It's Production-Ready</h2>
<p>Lovable is excellent for exploring ideas quickly.</p>
<p>You can use it to:</p>
<ul>
<li><p>Test a product concept</p>
</li>
<li><p>Build a portfolio project</p>
</li>
<li><p>Create a prototype for user feedback</p>
</li>
<li><p>Learn how web applications are structured</p>
</li>
<li><p>Experiment with interfaces</p>
</li>
<li><p>Build an internal tool</p>
</li>
<li><p>Turn a rough idea into something people can react to</p>
</li>
</ul>
<p>A prototype may not have the same security, reliability, monitoring, documentation, and scalability requirements as a public production application.</p>
<p>Be honest about the stage of your project. Use labels such as "Prototype", "Demo", "Work in Progress", and so on.</p>
<p>Don't treat a prototype like a finished product simply because it has a nice gradient and a button that says “Launch.”</p>
<h2 id="heading-create-a-simple-responsible-development-workflow">Create a Simple Responsible Development Workflow</h2>
<p>A practical workflow might look like this:</p>
<ol>
<li><p>Define the problem</p>
</li>
<li><p>Identify the users</p>
</li>
<li><p>Decide what information the app needs</p>
</li>
<li><p>Write a clear prompt</p>
</li>
<li><p>Generate a small feature</p>
</li>
<li><p>Review the result</p>
</li>
<li><p>Test normal and unexpected behavior</p>
</li>
<li><p>Fix security and accessibility problems</p>
</li>
<li><p>Repeat for the next feature</p>
</li>
<li><p>Test the complete app</p>
</li>
<li><p>Remove test data and secrets</p>
</li>
<li><p>Document important decisions</p>
</li>
<li><p>Deploy only when the app is ready for its intended audience</p>
</li>
</ol>
<p>This process isn't slow. It's controlled. The fastest path is often the one that avoids rebuilding the entire application after discovering that the foundation was made of optimism and unvalidated form fields.</p>
<h2 id="heading-a-responsible-prompt-template">A Responsible Prompt Template</h2>
<p>You can use this template when asking Lovable to create a feature:</p>
<pre><code class="language-text">Build [feature] for [type of user].

The goal is to [explain the problem being solved].

Users should be able to:
- [action one]
- [action two]
- [action three]

The application should:
- Validate all user input.
- Protect authenticated routes.
- Ensure users can access only data they are authorized to access.
- Avoid hardcoded secrets.
- Use clear loading and error states.
- Support keyboard navigation.
- Work on mobile and desktop screens.
- Use accessible labels and sufficient color contrast.

Do not:
- Collect unnecessary personal information.
- Expose private data.
- Add unrelated features.
- Change existing authentication behavior without explaining the change.

After building the feature, explain:
- Which files changed.
- What data is stored.
- Which external services are used.
- What security risks remain.
- How I should test the feature.
</code></pre>
<p>This template encourages Lovable to think about more than appearance.</p>
<h2 id="heading-the-golden-rule-of-ai-app-building">The Golden Rule of AI App Building</h2>
<p>If an AI-generated feature affects another person, review it as if you will be the person affected.</p>
<p>Would you want your data stored there?</p>
<p>Would you understand what the app is doing?</p>
<p>Would you be able to correct a mistake?</p>
<p>Would you know how to delete your information?</p>
<p>Would you feel comfortable using the application on a phone, with a keyboard, or with a slow internet connection?</p>
<p>Would you trust the app if you knew how it was built?</p>
<p>These questions turn responsible development from an abstract idea into a practical habit.</p>
<h2 id="heading-final-thoughts">Final Thoughts</h2>
<p>Lovable can make app development more approachable, faster, and more fun. It can help beginners build their first projects and help experienced developers explore ideas without spending hours creating every screen from scratch.</p>
<p>But responsible use requires more than generating attractive interfaces. Write clear prompts. Protect secrets. Collect less data. Validate inputs. Test permissions. Design for accessibility. Respect ownership. Explain AI-generated features. Review the code. Keep people involved in important decisions.</p>
<p>The best AI-built applications aren't the ones created with the fewest clicks. They're the ones built with curiosity, care, and enough testing to survive contact with real users.</p>
<p>Use Lovable to move faster, but use your judgment to decide where you're going.</p>
<p>Happy coding!</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Protect Your Privacy While Browsing the Web ]]>
                </title>
                <description>
                    <![CDATA[ Every time you open a web page, you leave a trail. Your browser shares your IP address, your screen size, your fonts, and dozens of other small details. Ad companies collect these details and stitch t ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-protect-your-privacy-while-browsing-the-web/</link>
                <guid isPermaLink="false">6a96f3e0541d63c1fd799f01</guid>
                
                    <category>
                        <![CDATA[ privacy ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Browsers ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Manish Shivanandhan ]]>
                </dc:creator>
                <pubDate>Tue, 01 Sep 2026 15:48:48 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/7e2faa59-b805-4df1-be97-86c74604f35f.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Every time you open a web page, you leave a trail. Your browser shares your IP address, your screen size, your fonts, and dozens of other small details. Ad companies collect these details and stitch them together. The result is a profile of who you are, what you buy, and where you go.</p>
<p>The good news is that you don't need to be a security expert to fix this. Most of the work takes an afternoon. After that, privacy becomes a habit rather than a project. This guide walks through the steps in the order that gives you the biggest gain for the least effort.</p>
<p>In this article, you'll learn how to improve your online privacy without making browsing complicated. We'll cover the most important steps, from choosing a privacy-friendly browser and blocking trackers to using a VPN, securing your search history and DNS, and cleaning up your online accounts.</p>
<p>You'll also learn what these tools can and can't protect, so you can build a practical privacy setup that works for everyday browsing.</p>
<h3 id="heading-what-well-cover">What We'll Cover:</h3>
<ul>
<li><p><a href="#heading-start-with-your-browser">Start With Your Browser</a></p>
</li>
<li><p><a href="#heading-block-trackers-and-ads">Block Trackers and Ads</a></p>
</li>
<li><p><a href="#heading-hide-your-ip-address-with-a-vpn">Hide Your IP Address With a VPN</a></p>
</li>
<li><p><a href="#heading-fix-your-search-and-dns">Fix Your Search and DNS</a></p>
</li>
<li><p><a href="#heading-clean-up-your-accounts">Clean Up Your Accounts</a></p>
</li>
<li><p><a href="#heading-know-what-each-step-cannot-do">Know What Each Step Cannot Do</a></p>
</li>
<li><p><a href="#heading-a-simple-plan">A Simple Plan</a></p>
</li>
</ul>
<h2 id="heading-start-with-your-browser">Start With Your Browser</h2>
<p>Your browser is the front door. If it leaks, nothing else matters much.</p>
<p>Chrome is fast, but it belongs to an advertising company. Firefox and Brave block trackers by default and are built with privacy in mind. Safari also does a decent job on Apple devices.</p>
<p>If you want to switch, <a href="https://www.mozilla.org/firefox/">Firefox</a> is the easiest move. It imports your bookmarks and saved passwords in a couple of clicks.</p>
<p>If you stay with Chrome, change a few settings. Turn off third-party cookies. Turn off the ad topics feature, which lets Chrome guess your interests and share them with sites.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c6d8f04fa7fe6a6e337edd/6ff49487-14cc-4e74-9504-0e47c627c30e.png" alt="Chrome Privacy Settings" style="display: block;" width="3010" height="1606" loading="lazy">

<p>Set your browser to clear cookies when you close it. You'll sign in to sites more often, but trackers will lose their memory of you every day.</p>
<p>Also check what your browser is telling the world right now. The Electronic Frontier Foundation runs a free test called <a href="https://coveryourtracks.eff.org/">Cover Your Tracks</a>. It shows you how unique your browser looks to a tracking company.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c6d8f04fa7fe6a6e337edd/95ffa5d4-2e8e-4914-add6-5b64f7d853ba.png" alt="Cover Your Tracks" style="display: block;" width="3254" height="1830" loading="lazy">

<p>Run it before you make changes, then run it again after. The difference is a good reality check.</p>
<h2 id="heading-block-trackers-and-ads">Block Trackers and Ads</h2>
<p>Most tracking happens through scripts loaded from other companies. A page may look like one site, but it can quietly load code from ten different firms.</p>
<p>A content blocker stops those scripts before they run. <a href="https://ublockorigin.com/">uBlock Origin</a> is the best-known free option and works in Firefox. It blocks ads too, which makes pages load faster and use less data. Privacy Badger, also from the EFF, takes a different approach. It watches which companies follow you across sites and blocks the ones that do.</p>
<p>Don't install multiple blockers at once. Two is plenty. More than that causes broken pages and slow browsing, and the extra protection is small.</p>
<p>Be careful about which extensions you trust. An extension can read everything on every page you visit. Stick to well-known tools with a clear privacy policy and a long track record. If an extension is free, tiny, and asks for wide permissions, skip it.</p>
<h2 id="heading-hide-your-ip-address-with-a-vpn">Hide Your IP Address With a VPN</h2>
<p>Blocking trackers helps, but your internet provider still sees every site you visit. In many countries, providers are allowed to sell that data. Public Wi-Fi at a hotel or airport is worse, because other people on the network may be watching, too.</p>
<p>A virtual private network fixes this. It wraps your traffic in an encrypted tunnel and sends it through a server run by the VPN company. Your provider sees only that you connected to the VPN. Websites see the VPN server's address instead of yours.</p>
<p>If you want protection only inside your browser, a browser extension is the simplest route. The <a href="https://www.cyberghostvpn.com/download/chrome-vpn">CyberGhost Chrome add-on</a> is one example. It routes your Chrome traffic through an encrypted proxy, lets you pick a country, and blocks some trackers along the way. Anything outside Chrome, like a mail client or a game, still uses your normal connection. That's fine for casual privacy and it keeps your other apps fast.</p>
<p>One warning about free VPNs: running servers costs money. If a VPN is free and shows no clear business model, your data may be the product. Pick a paid service with a published no-logs audit, or use a free plan from a company you already trust.</p>
<h2 id="heading-fix-your-search-and-dns">Fix Your Search and DNS</h2>
<p>Your search history is the most personal thing you own. It holds your health worries, your money problems, and your plans.</p>
<p>Google keeps that history tied to your account unless you tell it not to. Visit <a href="https://myactivity.google.com/">My Activity</a> and set auto-delete to three months. While you're there, look at what has been saved. Most people find the amount surprising.</p>
<p>You can also switch your default search engine. DuckDuckGo and Startpage don't build a profile from your searches. Results are good enough for daily use, and you can still fall back to Google when you need it.</p>
<p>Then there's DNS, the system that turns a web address into a number your computer can reach. By default your provider handles this, so it sees every domain you visit even when the traffic itself is encrypted. Switching to an encrypted DNS service such as <a href="https://1.1.1.1/">Cloudflare's 1.1.1.1</a> takes about two minutes and closes that gap. Most browsers now have a setting for secure DNS, so you may not even need to touch your system settings.</p>
<h2 id="heading-clean-up-your-accounts">Clean Up Your Accounts</h2>
<p>Privacy isn't only about traffic. It's also about the data already sitting on other people's servers.</p>
<p>Start by finding out where your details have leaked. The free site <a href="https://haveibeenpwned.com/">Have I Been Pwned</a> tells you which breaches include your email address. If your address shows up in an old breach, change that password right away, and change it anywhere else you reused it.</p>
<p>Reused passwords are the single biggest risk most people carry. A password manager solves it. It creates a long random password for every site and remembers them all for you. Turn on two-factor authentication for your email, your bank, and any account tied to money. Email matters most, because whoever controls your email can reset everything else.</p>
<p>Finally, use different email addresses for different parts of your life. Many providers let you create aliases. One address for banking, one for shopping, and one for newsletters. When an alias starts getting spam, you know exactly who sold your data, and you can shut it off without changing your real address.</p>
<h2 id="heading-know-what-each-step-cant-do">Know What Each Step Can't Do</h2>
<p>It helps to be clear about limits, so you don't feel safer than you are.</p>
<p>A VPN hides your traffic from your provider, but it doesn't hide you from a site you log in to. If you sign in to a social network, that network knows it's you no matter which country your traffic seems to come from.</p>
<p>Blocking trackers reduces profiling, but browser fingerprinting can still identify you through your unique mix of settings. Only tools like the <a href="https://www.torproject.org/">Tor Browser</a>, which makes everyone look the same, defeat that fully. Tor is slower, so most people save it for the moments that really matter.</p>
<p>And nothing protects information you hand over yourself. If you post your holiday plans in public, no setting will pull them back.</p>
<h2 id="heading-a-simple-plan">A Simple Plan</h2>
<p>If you only do three things, do these. Install a good content blocker, turn on a password manager and two-factor authentication, and use a VPN whenever you're on a network you don't control.</p>
<p>Then set a reminder for six months from now. Check your breach report, review the extensions you've installed, and delete the accounts you no longer use. Privacy isn't a wall you build once. It's closer to housekeeping. A little attention now and then keeps the mess from piling up, and it costs far less than cleaning up after your data has already escaped.</p>
<p>Hope you enjoyed this article. You can <a href="https://linkedin.com/in/manishmshiva">connect with me on LinkedIn</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Fix a Leaked API Key: A Developer’s Guide to Git Security ]]>
                </title>
                <description>
                    <![CDATA[ Imagine this: you're working late, your code finally works, and you're ready to push it to GitHub. You run: git add . git commit -m "Fix API integration" git push A few minutes later, you notice some ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-fix-a-leaked-api-key/</link>
                <guid isPermaLink="false">6a8ddd55902e76128f1985bb</guid>
                
                    <category>
                        <![CDATA[ Git ]]>
                    </category>
                
                    <category>
                        <![CDATA[ software development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ api ]]>
                    </category>
                
                    <category>
                        <![CDATA[ GitHub ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Eva J Patel ]]>
                </dc:creator>
                <pubDate>Tue, 25 Aug 2026 18:22:13 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/0903575c-822b-481b-af12-07b864bebc67.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Imagine this: you're working late, your code finally works, and you're ready to push it to GitHub.</p>
<p>You run:</p>
<pre><code class="language-bash">git add .
git commit -m "Fix API integration"
git push
</code></pre>
<p>A few minutes later, you notice something strange. Your API usage has suddenly increased. Maybe there are unexpected requests, new cloud resources, or even a bill that looks much larger than expected.</p>
<p>Then you find it:</p>
<pre><code class="language-javascript">const apiKey = "sk_live_123456789";
</code></pre>
<p>Your API key is sitting in a Git repository.</p>
<p>This situation is stressful, but it's fixable.</p>
<p>The most important rule is:</p>
<blockquote>
<p><strong>If an API key has been committed to Git, assume it has been copied and compromised, even if you delete it immediately.</strong></p>
</blockquote>
<p>Deleting the key from the latest version of your file doesn't make the old key safe. Git keeps previous versions of files in its history, and exposed credentials can be discovered by automated scanners.</p>
<p>In this guide, you'll learn the following:</p>
<ul>
<li><p><a href="#heading-what-is-an-api-key">What Is an API Key?</a></p>
</li>
<li><p><a href="#heading-the-emergency-response-what-to-do-first">The Emergency Response: What to Do First</a></p>
</li>
<li><p><a href="#heading-step-1-revoke-or-rotate-the-leaked-key">Step 1: Revoke or Rotate the Leaked Key</a></p>
</li>
<li><p><a href="#heading-step-2-investigate-suspicious-activity">Step 2: Investigate Suspicious Activity</a></p>
</li>
<li><p><a href="#heading-step-3-remove-the-secret-from-your-current-code">Step 3: Remove the Secret From Your Current Code</a></p>
</li>
<li><p><a href="#heading-step-4-use-a-env-file-for-local-development">Step 4: Use a.envFile for Local Development</a></p>
</li>
<li><p><a href="#heading-step-5-create-a-safe-envexample">Step 5: Create a Safe.env.example</a></p>
</li>
<li><p><a href="#heading-step-6-determine-whether-the-secret-is-still-in-git-history">Step 6: Determine Whether the Secret Is Still in Git History</a></p>
</li>
<li><p><a href="#heading-when-do-you-need-to-rewrite-git-history">When Do You Need to Rewrite Git History?</a></p>
</li>
<li><p><a href="#heading-step-7-remove-the-secret-from-git-history">Step 7: Remove the Secret From Git History</a></p>
</li>
<li><p><a href="#heading-step-8-verify-that-the-secret-is-gone">Step 8: Verify That the Secret Is Gone</a></p>
</li>
<li><p><a href="#heading-step-9-push-the-cleaned-history-carefully">Step 9: Push the Cleaned History Carefully</a></p>
</li>
<li><p><a href="#heading-step-10-replace-the-credential-everywhere">Step 10: Replace the Credential Everywhere</a></p>
</li>
<li><p><a href="#heading-step-11-restrict-the-replacement-key">Step 11: Restrict the Replacement Key</a></p>
</li>
<li><p><a href="#heading-what-about-frontend-applications">What About Frontend Applications?</a></p>
</li>
<li><p><a href="#heading-environment-variables-vs-secret-managers">Environment Variables vs Secret Managers</a></p>
</li>
<li><p><a href="#heading-add-secret-scanning-to-your-workflow">Add Secret Scanning to Your Workflow</a></p>
</li>
<li><p><a href="#heading-use-git-hooks-as-an-extra-safety-net">Use Git Hooks as an Extra Safety Net</a></p>
</li>
<li><p><a href="#heading-review-your-staged-diff-before-committing">Review Your Staged Diff Before Committing</a></p>
</li>
<li><p><a href="#heading-common-mistakes-developers-make">Common Mistakes Developers Make</a></p>
</li>
<li><p><a href="#heading-a-complete-api-key-incident-checklist">A Complete API-Key Incident Checklist</a></p>
</li>
<li><p><a href="#heading-a-secure-project-structure">A Secure Project Structure</a></p>
</li>
</ul>
<p>We'll use this basic workflow throughout the article:</p>
<pre><code class="language-text">Invalidate → Investigate → Remove → Replace → Prevent
</code></pre>
<p>Let's start with what an API key actually is before we get to the most important part: what to do <strong>right now</strong> after a key is exposed.</p>
<h2 id="heading-what-is-an-api-key">What Is an API Key?</h2>
<p>An API key is a credential that allows an application to communicate with another service.</p>
<p>For example, an application might use an API key to access:</p>
<ul>
<li><p>A weather service</p>
</li>
<li><p>A payment provider</p>
</li>
<li><p>A mapping service</p>
</li>
<li><p>An artificial intelligence API</p>
</li>
<li><p>A cloud platform</p>
</li>
<li><p>A database</p>
</li>
<li><p>An email provider</p>
</li>
<li><p>A private company API</p>
</li>
</ul>
<p>A key might look something like this:</p>
<pre><code class="language-javascript">const apiKey = "your-real-api-key";
</code></pre>
<p>Or it might appear in a configuration file:</p>
<pre><code class="language-json">{
  "apiKey": "your-real-api-key",
  "databasePassword": "your-real-password"
}
</code></pre>
<p>API keys are often called <strong>secrets</strong> because possessing one may allow someone to make requests, access data, create resources, or generate charges on your account.</p>
<p>Not every API key is equally sensitive. Some services provide browser keys that are intentionally visible to users. Those keys should still have appropriate restrictions, quotas, and permissions.</p>
<p>As a general rule:</p>
<blockquote>
<p><strong>If a credential can access private data, create resources, modify records, or generate charges, it shouldn't be stored directly in your source code.</strong></p>
</blockquote>
<h2 id="heading-the-emergency-response-what-to-do-first">The Emergency Response: What to Do First</h2>
<p>When you discover a leaked credential, a common reaction is to delete the key from the file and push another commit.</p>
<p>Don't start there.</p>
<p>Your first priority is to <strong>make the leaked credential useless</strong>.</p>
<p>Use this order of operations:</p>
<pre><code class="language-text">1. Invalidate the leaked credential
2. Investigate suspicious activity
3. Remove the secret from your code
4. Replace it with a new credential
5. Clean the Git history if necessary
6. Verify the cleanup
7. Add protections against future leaks
</code></pre>
<p>Think of an API key like a house key that was dropped in a crowded street.</p>
<p>Deleting a picture of the key doesn't matter if someone already picked up the physical key.</p>
<p><strong>Change the lock first.</strong></p>
<h2 id="heading-step-1-revoke-or-rotate-the-leaked-key">Step 1: Revoke or Rotate the Leaked Key</h2>
<p>Go to the dashboard of the service that issued the credential.</p>
<p>Depending on the provider, you may see options such as:</p>
<ul>
<li><p>Revoke</p>
</li>
<li><p>Delete</p>
</li>
<li><p>Disable</p>
</li>
<li><p>Rotate</p>
</li>
<li><p>Regenerate</p>
</li>
<li><p>Create new key</p>
</li>
</ul>
<p>If the provider supports key rotation, create a replacement credential before disabling the old one if possible. This can reduce application downtime while you update your configuration.</p>
<p>The important thing is that the original credential must no longer be usable.</p>
<p><strong>Do not reuse the leaked key.</strong> Don't rename it. Don't encode it. Don't move it to another file and assume it is safe. Don't assume nobody saw it.</p>
<p>Treat it as compromised.</p>
<h2 id="heading-step-2-investigate-suspicious-activity">Step 2: Investigate Suspicious Activity</h2>
<p>After disabling the credential, check the provider's usage dashboard and logs.</p>
<p>Look for things such as:</p>
<ul>
<li><p>Sudden spikes in requests</p>
</li>
<li><p>Requests from unfamiliar locations</p>
</li>
<li><p>Unexpected database queries</p>
</li>
<li><p>New cloud resources</p>
</li>
<li><p>Changes to permissions</p>
</li>
<li><p>Unexpected downloads</p>
</li>
<li><p>Unusual payment activity</p>
</li>
<li><p>New deployments</p>
</li>
<li><p>Requests at times when your application was inactive</p>
</li>
</ul>
<p>If the credential had broad permissions, assume that anything within its permission scope <strong>MAY have been accessed or modified</strong>.</p>
<p>For example, if a cloud credential could create virtual machines, check whether unexpected machines were created.</p>
<p>If a credential could access a database, review:</p>
<ul>
<li><p>Authentication logs</p>
</li>
<li><p>Read operations</p>
</li>
<li><p>Write operations</p>
</li>
<li><p>Deleted records</p>
</li>
<li><p>Exported data</p>
</li>
<li><p>Newly created accounts</p>
</li>
<li><p>Permission changes</p>
</li>
</ul>
<p>Also check your billing information if the credential could generate usage-based charges.</p>
<p>Write down what you discover. A simple timeline can help:</p>
<pre><code class="language-text">10:15 - API key committed
10:23 - Repository pushed publicly
10:41 - Unusual usage detected
10:45 - Key revoked
11:00 - Logs reviewed
11:30 - Replacement key deployed
12:00 - Git history cleaned
</code></pre>
<p>This can be especially useful if you need to report the incident to a team or service provider.</p>
<h2 id="heading-step-3-remove-the-secret-from-your-current-code">Step 3: Remove the Secret From Your Current Code</h2>
<p>Once the original credential has been disabled, remove it from your working files.</p>
<p>This is unsafe:</p>
<pre><code class="language-javascript">const apiKey = "your-real-api-key";
</code></pre>
<p>Instead, load the credential from the environment:</p>
<pre><code class="language-javascript">const apiKey = process.env.API_KEY;

if (!apiKey) {
  throw new Error("API_KEY is not configured");
}
</code></pre>
<p>In Python:</p>
<pre><code class="language-python">import os

api_key = os.environ.get("API_KEY")

if not api_key:
    raise RuntimeError("API_KEY is not configured")
</code></pre>
<p>The important idea is simple:</p>
<pre><code class="language-text">Source code → environment variable → secret value
</code></pre>
<p>instead of:</p>
<pre><code class="language-text">Source code → hardcoded secret
</code></pre>
<p>Environment variables aren't the only way to manage secrets, but they are a common and practical solution for local development and many deployment environments.</p>
<h2 id="heading-step-4-use-a-env-file-for-local-development">Step 4: Use a <code>.env</code> File for Local Development</h2>
<p>For local development, you can store environment variables in a <code>.env</code> file.</p>
<p>For example:</p>
<pre><code class="language-env">API_KEY=your-local-development-key
DATABASE_URL=your-local-database-url
</code></pre>
<p>A Node.js project can load these values with a package such as <code>dotenv</code>.</p>
<p>Install it with:</p>
<pre><code class="language-bash">npm install dotenv
</code></pre>
<p>Then:</p>
<pre><code class="language-javascript">import "dotenv/config";

const apiKey = process.env.API_KEY;
</code></pre>
<p>The important part is that the <code>.env</code> file normally <strong>should not be committed to Git</strong>.</p>
<p>Add it to <code>.gitignore</code>:</p>
<pre><code class="language-gitignore"># Environment files
.env
.env.*
!.env.example

# Credential files
*.pem
*.key
credentials.json
service-account.json

# Local development files
.DS_Store
</code></pre>
<p>But there's an important detail here: the <code>.gitignore</code> <strong>does NOT remove files that Git is already tracking.</strong></p>
<p>If <code>.env</code> has already been committed, adding it to <code>.gitignore</code> won't erase it from Git.</p>
<p>You can stop tracking the file while keeping it on your computer:</p>
<pre><code class="language-bash">git rm --cached .env
</code></pre>
<p>Then commit the <code>.gitignore</code> change:</p>
<pre><code class="language-bash">git add .gitignore
git commit -m "Ignore local environment files"
</code></pre>
<p>But remember: this only removes the file from future commits. It does <strong>not</strong> remove the secret from previous commits.</p>
<p>That's where Git history comes in.</p>
<h2 id="heading-step-5-create-a-safe-envexample">Step 5: Create a Safe <code>.env.example</code></h2>
<p>Other developers still need to know which environment variables the application requires.</p>
<p>Instead of committing <code>.env</code>, create <code>.env.example</code>:</p>
<pre><code class="language-env">API_KEY=
DATABASE_URL=
PORT=3000
LOG_LEVEL=info
</code></pre>
<p>This file contains variable names rather than real credentials, so it can be committed to the repository.</p>
<p>You can also provide comments:</p>
<pre><code class="language-env"># Required API credential
API_KEY=

# PostgreSQL connection string
DATABASE_URL=

# Optional application port
PORT=3000
</code></pre>
<p>A new developer can then copy the file:</p>
<pre><code class="language-bash">cp .env.example .env
</code></pre>
<p>and provide their own values.</p>
<p>Use clearly fake placeholders in examples:</p>
<pre><code class="language-env">API_KEY=replace-me-with-your-own-key
</code></pre>
<p>Avoid putting realistic-looking production credentials into <code>.env.example</code>.</p>
<h2 id="heading-step-6-determine-whether-the-secret-is-still-in-git-history">Step 6: Determine Whether the Secret Is Still in Git History</h2>
<p>This is one of the most important parts of fixing a leaked credential.</p>
<p>Suppose your Git history looks like this:</p>
<pre><code class="language-text">Commit A: Add API key to config.js
Commit B: Update API integration
Commit C: Delete API key
</code></pre>
<p>Even though Commit C no longer contains the key, Commit A still does.</p>
<p>Git remembers previous versions of your files.</p>
<p>You can inspect the history of a file with:</p>
<pre><code class="language-bash">git log --all -- config.js
</code></pre>
<p>To display a file from an older commit:</p>
<pre><code class="language-bash">git show COMMIT_ID:config.js
</code></pre>
<p>You can also search Git history for a known leaked value:</p>
<pre><code class="language-bash">git log --all -S"your-leaked-key" --oneline
</code></pre>
<p>If you know the secret was committed, you should assume that it exists somewhere in the repository's history until you've verified otherwise.</p>
<h2 id="heading-when-do-you-need-to-rewrite-git-history">When Do You Need to Rewrite Git History?</h2>
<p>Not every accidental secret requires a history rewrite. Consider these situations:</p>
<h3 id="heading-the-secret-was-never-committed">The Secret Was Never Committed</h3>
<p>If the secret exists only in your working directory and was never committed, you generally don't need to rewrite history.</p>
<p>Remove it, add the appropriate file to <code>.gitignore</code>, and continue.</p>
<h3 id="heading-the-secret-was-committed-locally-but-never-pushed">The Secret Was Committed Locally But Never Pushed</h3>
<p>If the secret exists in local commits but hasn't been shared with a remote repository, you may be able to clean up those commits before pushing.</p>
<h3 id="heading-the-secret-was-pushed-to-a-remote-repository">The Secret Was Pushed to a Remote Repository</h3>
<p>Treat the credential as compromised. Revoke or rotate it immediately.</p>
<p>Then determine whether removing the secret from the repository's history is appropriate.</p>
<h3 id="heading-the-repository-was-public">The Repository Was Public</h3>
<p>Assume that someone or something may already have copied the secret.</p>
<p>This is why <strong>revocation comes before Git cleanup</strong>.</p>
<h3 id="heading-the-secret-was-in-a-private-repository">The Secret Was in a Private Repository</h3>
<p>A private repository is safer than a public repository, but it isn't a secret vault.</p>
<p>Credentials can still escape through:</p>
<ul>
<li><p>Compromised accounts</p>
</li>
<li><p>Contractors</p>
</li>
<li><p>Integrations</p>
</li>
<li><p>CI logs</p>
</li>
<li><p>Forks</p>
</li>
<li><p>Backups</p>
</li>
<li><p>Screenshots</p>
</li>
<li><p>Copied code</p>
</li>
<li><p>Pull requests</p>
</li>
</ul>
<p>So the safest rule remains:</p>
<blockquote>
<p><strong>Never intentionally commit credentials to Git, even in a private repository.</strong></p>
</blockquote>
<h2 id="heading-step-7-remove-the-secret-from-git-history">Step 7: Remove the Secret From Git History</h2>
<p>If the credential was committed, you may need to remove it from the repository's history.</p>
<p>Before rewriting history, create a backup:</p>
<pre><code class="language-bash">git clone --mirror https://github.com/your-username/your-repository.git repository-backup.git
</code></pre>
<p>A mirror clone includes branches and tags, which makes it useful for recovery if something goes wrong.</p>
<h3 id="heading-option-1-remove-an-entire-file">Option 1: Remove an Entire File</h3>
<p>If the secret was stored in a file such as <code>.env</code>, you can remove that file from the entire history:</p>
<pre><code class="language-bash">git filter-repo --path .env --invert-paths
</code></pre>
<p>For a file inside a directory:</p>
<pre><code class="language-bash">git filter-repo --path config/production.json --invert-paths
</code></pre>
<p>This removes the file from the repository's rewritten history.</p>
<h3 id="heading-option-2-replace-a-secret-inside-a-file">Option 2: Replace a Secret Inside a File</h3>
<p>Sometimes you need to keep the file but remove the secret from previous versions.</p>
<p>Create a temporary replacements file:</p>
<p>Then run:</p>
<pre><code class="language-bash">git filter-repo --replace-text replacements.txt
</code></pre>
<p>You can replace the value with a placeholder:</p>
<pre><code class="language-text">your-leaked-key==&gt;YOUR_API_KEY_HERE
</code></pre>
<p>Be extremely careful with <code>replacements.txt</code>. It contains the original secret, so <strong>do not commit it.</strong></p>
<p>Delete it after the cleanup:</p>
<pre><code class="language-bash">rm replacements.txt
</code></pre>
<p>On Windows PowerShell:</p>
<pre><code class="language-powershell">Remove-Item replacements.txt
</code></pre>
<p>For multiple secrets:</p>
<pre><code class="language-text">old-api-key==&gt;REMOVED_API_KEY
old-database-password==&gt;REMOVED_DATABASE_PASSWORD
old-token==&gt;REMOVED_TOKEN
</code></pre>
<p>Then:</p>
<pre><code class="language-bash">git filter-repo --replace-text replacements.txt
</code></pre>
<p>Test the cleanup on your backup clone first.</p>
<h2 id="heading-step-8-verify-that-the-secret-is-gone">Step 8: Verify That the Secret Is Gone</h2>
<p>Never assume the cleanup worked just because the command completed successfully.</p>
<p>Search for the known leaked value again:</p>
<pre><code class="language-bash">git log --all -S"your-leaked-key" --oneline
</code></pre>
<p>You can also inspect relevant files and commits:</p>
<pre><code class="language-bash">git log --all -- config.js
</code></pre>
<p>and:</p>
<pre><code class="language-bash">git show COMMIT_ID:config.js
</code></pre>
<p>If your repository uses branches and tags, make sure you aren't checking only the branch you currently have checked out.</p>
<p>You should also inspect other locations where the secret may have appeared, including pull requests, CI/CD logs, build artifacts, release files, Docker images, package releases, documentation, issue comments, and screenshots</p>
<p>Remember:</p>
<blockquote>
<p><strong>Rewriting your repository doesn't erase copies that already exist somewhere else.</strong></p>
</blockquote>
<p>That's another reason why the original credential must be revoked.</p>
<h2 id="heading-step-9-push-the-cleaned-history-carefully">Step 9: Push the Cleaned History Carefully</h2>
<p>Once you've verified the cleanup, you may need to push the rewritten history:</p>
<pre><code class="language-bash">git push --force --all origin
git push --force --tags origin
</code></pre>
<h3 id="heading-important-warning">Important Warning</h3>
<p><strong>Force-pushing rewritten history is disruptive.</strong> It changes commit hashes and can affect collaborators who have existing clones of the repository.</p>
<p>Before doing this on a shared project:</p>
<ol>
<li><p>Tell your collaborators.</p>
</li>
<li><p>Make sure everyone understands that history is being rewritten.</p>
</li>
<li><p>Coordinate the cleanup.</p>
</li>
<li><p>Follow your organization's incident-response process if one exists.</p>
</li>
</ol>
<p>After the rewrite, collaborators may need to reclone the repository:</p>
<pre><code class="language-bash">git clone https://github.com/your-username/your-repository.git
</code></pre>
<p>They shouldn't blindly merge their old repository history back into the cleaned repository.</p>
<h2 id="heading-step-10-replace-the-credential-everywhere">Step 10: Replace the Credential Everywhere</h2>
<p>Now create or use the replacement credential. Update every environment where the application runs. Common locations include:</p>
<ul>
<li><p>Local development</p>
</li>
<li><p>Testing</p>
</li>
<li><p>Staging</p>
</li>
<li><p>Production</p>
</li>
<li><p>Docker containers</p>
</li>
<li><p>Kubernetes secrets</p>
</li>
<li><p>CI/CD systems</p>
</li>
<li><p>Hosting platforms</p>
</li>
<li><p>Scheduled jobs</p>
</li>
<li><p>Serverless functions</p>
</li>
</ul>
<p>A common mistake is updating production but forgetting the deployment pipeline.</p>
<p>For example, your local application may work because <code>.env</code> contains the new key, while your CI/CD system still contains the old one.</p>
<p>Make a checklist:</p>
<pre><code class="language-text">1. Local development
2. Automated tests
3. Staging
4. Production
5. CI/CD variables
6. Docker configuration
7. Cloud deployment settings
8. Scheduled scripts
9. Serverless functions
</code></pre>
<p>After updating the credential, test the application in each important environment.</p>
<h2 id="heading-step-11-restrict-the-replacement-key">Step 11: Restrict the Replacement Key</h2>
<p>Replacing a leaked credential is only part of the solution.</p>
<p>The new credential should have <strong>only the permissions it actually needs</strong>.</p>
<p>Useful restrictions can include:</p>
<ul>
<li><p>Read-only permissions</p>
</li>
<li><p>Specific API scopes</p>
</li>
<li><p>Allowed IP addresses</p>
</li>
<li><p>Allowed domains</p>
</li>
<li><p>Environment-specific access</p>
</li>
<li><p>Request quotas</p>
</li>
<li><p>Rate limits</p>
</li>
<li><p>Expiration dates</p>
</li>
</ul>
<p>For example, a weather application may only need permission to read weather data.</p>
<p>It shouldn't have permission to manage users, modify billing, or delete unrelated resources.</p>
<p>This is the <strong>principle of least privilege</strong>:</p>
<blockquote>
<p><strong>Give each credential the smallest amount of access necessary to perform its job.</strong></p>
</blockquote>
<p>It's also a good idea to use different credentials for different environments:</p>
<pre><code class="language-text">local-development-key
testing-key
staging-key
production-key
</code></pre>
<p>That way, a development credential leak doesn't automatically expose production resources.</p>
<h2 id="heading-what-about-frontend-applications">What About Frontend Applications?</h2>
<p>This is where API-key security gets confusing.</p>
<p>Frontend code runs on the user's device.</p>
<p>That means users can inspect it.</p>
<p>For example:</p>
<pre><code class="language-javascript">const apiKey = "browser-key";
</code></pre>
<p>A user can inspect the JavaScript bundle, browser developer tools, or network requests and potentially see the value.</p>
<p>Some services intentionally provide browser API keys that are designed to be publicly visible.</p>
<p>Those keys should still be restricted by things such as:</p>
<ul>
<li><p>Allowed domains</p>
</li>
<li><p>Website origins</p>
</li>
<li><p>API operations</p>
</li>
<li><p>Usage quotas</p>
</li>
<li><p>Referrer restrictions</p>
</li>
<li><p>Time limits</p>
</li>
</ul>
<p>But a truly private credential should <strong>never be placed in browser code</strong>.</p>
<p>Instead of:</p>
<pre><code class="language-javascript">fetch("https://private-api.example.com/data", {
  headers: {
    Authorization: "Bearer private-secret-token"
  }
});
</code></pre>
<p>have the browser call your own backend:</p>
<pre><code class="language-javascript">fetch("/api/data");
</code></pre>
<p>Then the backend communicates with the private service:</p>
<pre><code class="language-javascript">const response = await fetch(
  "https://private-api.example.com/data",
  {
    headers: {
      Authorization: `Bearer ${process.env.PRIVATE_API_TOKEN}`
    }
  }
);
</code></pre>
<p>The backend can then return only the information the browser is allowed to receive.</p>
<p>The important distinction is:</p>
<pre><code class="language-text">Public/browser credential
        ↓
Can be visible, but should be restricted

Private credential
        ↓
Must remain on a trusted backend or secret-management system
</code></pre>
<h2 id="heading-environment-variables-vs-secret-managers">Environment Variables vs Secret Managers</h2>
<p>Environment variables are useful, but they're not a universal secret-management solution.</p>
<p>For a small application or local development environment, something like:</p>
<pre><code class="language-env">API_KEY=your-secret
</code></pre>
<p>may be perfectly reasonable.</p>
<p>For larger production systems, you may want a dedicated <strong>secret manager</strong>.</p>
<p>A secret-management system can provide features such as:</p>
<ul>
<li><p>Centralized credential storage</p>
</li>
<li><p>Access controls</p>
</li>
<li><p>Auditing</p>
</li>
<li><p>Credential rotation</p>
</li>
<li><p>Versioning</p>
</li>
<li><p>Separation between environments</p>
</li>
<li><p>Integration with deployment systems</p>
</li>
</ul>
<p>The important idea is that your source code shouldn't be responsible for storing production secrets.</p>
<p>Instead:</p>
<pre><code class="language-text">Application
    ↓
Secret management system
    ↓
Credential
</code></pre>
<p>rather than:</p>
<pre><code class="language-text">Application
    ↓
Hardcoded production credential
</code></pre>
<p>Which solution you use depends on the size and requirements of your project.</p>
<h2 id="heading-add-secret-scanning-to-your-workflow">Add Secret Scanning to Your Workflow</h2>
<p>Humans are excellent programmers and occasionally terrible search engines.</p>
<p>Automated secret scanning can catch credentials before they make it into a repository.</p>
<p>Popular tools include:</p>
<ul>
<li><p>Gitleaks</p>
</li>
<li><p>TruffleHog</p>
</li>
<li><p>detect-secrets</p>
</li>
<li><p>Pre-commit hooks</p>
</li>
<li><p>Git hosting secret scanning</p>
</li>
<li><p>CI security scanners</p>
</li>
</ul>
<p>For example, you can run Gitleaks locally:</p>
<pre><code class="language-bash">gitleaks detect --source . --verbose
</code></pre>
<p>You can also integrate secret scanning into CI.</p>
<p>A basic GitHub Actions workflow might look like this:</p>
<pre><code class="language-yaml">name: Secret Scan

on:
  push:
  pull_request:

jobs:
  scan:
    runs-on: ubuntu-latest

    steps:
      - name: Check out repository
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Scan for secrets
        uses: gitleaks/gitleaks-action@v2
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
</code></pre>
<p>Review the documentation for your chosen tool and pin versions according to your project's security practices.</p>
<p>Secret scanners can produce false positives, so you may need to configure exceptions for safe test values.</p>
<p>Be careful with allowlists, though. An overly broad exception can hide a real credential.</p>
<h2 id="heading-use-git-hooks-as-an-extra-safety-net">Use Git Hooks as an Extra Safety Net</h2>
<p>You can also scan files before they're committed.</p>
<p>For example, a simple pre-commit script could search for suspicious words:</p>
<pre><code class="language-bash">#!/usr/bin/env bash

if grep -RniE "api[_-]?key|password|secret|token|private[_-]?key" . \
  --exclude-dir=.git \
  --exclude=".env.example"; then

  echo "Possible secret detected. Commit cancelled."
  exit 1
fi
</code></pre>
<p>This isn't a complete security scanner, but it can catch obvious mistakes.</p>
<p>For stronger protection, use a dedicated secret-scanning tool through a pre-commit framework.</p>
<p>The goal isn't to make committing miserable. The goal is to make accidentally publishing a credential harder.</p>
<h2 id="heading-review-your-staged-diff-before-committing">Review Your Staged Diff Before Committing</h2>
<p>One of the simplest security habits you can develop is checking what you're actually about to commit.</p>
<p>First:</p>
<pre><code class="language-bash">git status
</code></pre>
<p>Then stage only the files you intend to commit:</p>
<pre><code class="language-bash">git add src/api.js README.md
</code></pre>
<p>Now inspect the staged changes:</p>
<pre><code class="language-bash">git diff --cached
</code></pre>
<p>Look for:</p>
<ul>
<li><p>API keys</p>
</li>
<li><p>Passwords</p>
</li>
<li><p>Tokens</p>
</li>
<li><p>Private URLs</p>
</li>
<li><p>Internal hostnames</p>
</li>
<li><p>Customer data</p>
</li>
<li><p>Debug output</p>
</li>
<li><p>Personal information</p>
</li>
<li><p>Private certificates</p>
</li>
</ul>
<p>Only commit after the staged diff looks correct:</p>
<pre><code class="language-bash">git commit -m "Load API key from environment"
</code></pre>
<p>Be cautious with:</p>
<pre><code class="language-bash">git add .
</code></pre>
<p>It can stage files you never intended to publish, including <code>.env</code> files, database exports, generated files, or local configuration.</p>
<h2 id="heading-common-mistakes-developers-make">Common Mistakes Developers Make</h2>
<h3 id="heading-mistake-1-i-deleted-it-so-its-fine">Mistake 1: "I Deleted It, So It's Fine"</h3>
<p>Deleting a secret from the current version of a file doesn't delete it from Git history.</p>
<p><strong>Correct response:</strong> Revoke the credential and clean the repository history when appropriate.</p>
<h3 id="heading-mistake-2-the-repository-is-private">Mistake 2: "The Repository Is Private"</h3>
<p>Private repositories aren't vaults.</p>
<p>Credentials can still escape through compromised accounts, integrations, CI logs, forks, backups, or copied code.</p>
<p><strong>Correct response:</strong> Don't commit secrets even to private repositories.</p>
<h3 id="heading-mistake-3-ill-just-encode-it">Mistake 3: "I'll Just Encode It"</h3>
<p>These don't make a credential secret:</p>
<pre><code class="language-javascript">const key = atob("c29tZS1rZXk=");
</code></pre>
<p>or:</p>
<pre><code class="language-javascript">const key = "some-" + "secret-" + "value";
</code></pre>
<p>Encoding, splitting, renaming, or hiding a credential doesn't protect it.</p>
<p>If your application can reconstruct the credential, someone analyzing the application may be able to do the same.</p>
<h3 id="heading-mistake-4-logging-the-secret">Mistake 4: Logging the Secret</h3>
<p>Don't do this:</p>
<pre><code class="language-javascript">console.log(process.env.API_KEY);
</code></pre>
<p>Logs can be stored by your terminal, CI system, hosting provider, monitoring platform, or cloud service.</p>
<p>Instead:</p>
<pre><code class="language-javascript">console.log(
  "API key configured:",
  Boolean(process.env.API_KEY)
);
</code></pre>
<p>If you absolutely need to inspect a value during debugging, avoid printing the full credential.</p>
<p>For example:</p>
<pre><code class="language-javascript">function maskSecret(value) {
  if (!value) return "not configured";
  if (value.length &lt;= 8) return "********";

  return `${value.slice(0, 4)}...${value.slice(-4)}`;
}

console.log(maskSecret(process.env.API_KEY));
</code></pre>
<p>Even masked credentials should be handled carefully.</p>
<h3 id="heading-mistake-5-using-the-same-credential-everywhere">Mistake 5: Using the Same Credential Everywhere</h3>
<p>If local development, testing, staging, and production all use the same credential, one leak can affect everything.</p>
<p><strong>Correct response:</strong> Use separate credentials with separate permissions.</p>
<h3 id="heading-mistake-6-cleaning-only-the-current-branch">Mistake 6: Cleaning Only the Current Branch</h3>
<p>A secret can remain in:</p>
<ul>
<li><p>Old branches</p>
</li>
<li><p>Tags</p>
</li>
<li><p>Pull requests</p>
</li>
<li><p>Other references</p>
</li>
</ul>
<p><strong>Correct response:</strong> Consider the entire repository when investigating and cleaning a leaked credential.</p>
<h3 id="heading-mistake-7-forgetting-build-artifacts">Mistake 7: Forgetting Build Artifacts</h3>
<p>A secret might also appear in:</p>
<ul>
<li><p>Compiled JavaScript bundles</p>
</li>
<li><p>Docker images</p>
</li>
<li><p>Downloadable releases</p>
</li>
<li><p>Published packages</p>
</li>
<li><p>Generated documentation</p>
</li>
</ul>
<p><strong>Correct response:</strong> Revoke the credential and identify affected artifacts that may need to be removed or replaced.</p>
<h2 id="heading-a-complete-api-key-incident-checklist">A Complete API-Key Incident Checklist</h2>
<p>If you discover that you've exposed an API key, use this checklist:</p>
<pre><code class="language-text">1. Revoke or rotate the leaked key
2. Create a replacement credential
3. Restrict the replacement credential
4. Review provider logs
5. Review billing and usage
6. Check for unauthorized resources
7. Remove the key from current files
8. Add secret files to .gitignore
9. Create or update .env.example
10. Search Git history
11. Check branches and tags
12. Remove the secret from Git history if necessary
13. Verify the old secret is gone
14. Force-push cleaned history if appropriate
15. Check pull requests and forks
16. Check CI and deployment logs
17. Update local configuration
18. Update staging configuration
19. Update production configuration
20. Update CI/CD secrets
21. Run a secret scanner
22. Document the incident
23. Add preventive security checks
</code></pre>
<p>The exact steps will depend on your provider and project, but the order matters: <strong>Invalidate first. Clean up second.</strong></p>
<h2 id="heading-a-secure-project-structure">A Secure Project Structure</h2>
<p>A simple Node.js project might look like this:</p>
<pre><code class="language-text">my-project/
├── src/
│   └── api.js
├── .env
├── .env.example
├── .gitignore
├── package.json
└── README.md
</code></pre>
<p>The local <code>.env</code> file contains the actual development value:</p>
<pre><code class="language-env">API_KEY=your-local-key
</code></pre>
<p>The <code>.env.example</code> file contains no real credential:</p>
<pre><code class="language-env">API_KEY=replace-me-with-your-own-key
</code></pre>
<p>The application reads the environment variable:</p>
<pre><code class="language-javascript">import "dotenv/config";

const apiKey = process.env.API_KEY;

if (!apiKey) {
  throw new Error("Missing API_KEY environment variable");
}

export async function getData() {
  const response = await fetch(
    "https://api.example.com/data",
    {
      headers: {
        Authorization: `Bearer ${apiKey}`
      }
    }
  );

  if (!response.ok) {
    throw new Error(
      `API request failed: ${response.status}`
    );
  }

  return response.json();
}
</code></pre>
<p>And <code>.gitignore</code> keeps the local environment file out of future commits:</p>
<pre><code class="language-gitignore">.env
.env.*
!.env.example

node_modules/
</code></pre>
<p>Finally, your README can explain the setup without exposing credentials:</p>
<p>Step 1: Copy the example environment file on your bash <code>cp .env.example .env</code></p>
<p>Step 2: Add your own API key to <code>.env</code>.</p>
<p>And last but not least, start your application!</p>
<pre><code class="language-bash">npm start
</code></pre>
<h2 id="heading-final-thoughts">Final Thoughts</h2>
<p>Leaking an API key doesn't mean you're a terrible developer. It just means your development workflow needs better guardrails.</p>
<p>The important thing is knowing how to respond quickly and how to prevent the same mistake from happening again.</p>
<p>Remember the emergency formula:</p>
<pre><code class="language-text">Invalidate → Investigate → Remove → Replace → Prevent
</code></pre>
<p>The important thing is knowing how to respond quickly and how to prevent the same mistake from happening again.</p>
<ul>
<li><p>Invalidate the leaked credential so it can no longer be used.</p>
</li>
<li><p>Investigate your logs, usage, and billing to determine whether it was abused.</p>
</li>
<li><p>Remove the secret from your current code and, when necessary, from Git history.</p>
</li>
<li><p>Replace it with a new credential that has only the permissions it needs.</p>
</li>
<li><p>Prevent future leaks with environment variables, secret managers, secret scanning, and careful Git practices.</p>
</li>
</ul>
<p>Git is excellent at remembering your project's history. That's useful when you accidentally delete an important function. But it's much less useful when that history contains a password.</p>
<p>So keep your code public when appropriate. And <strong>keep your secrets somewhere else.</strong></p>
<p>Happy coding!</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ The Hidden Risks Lurking in Your Cloud Infrastructure ]]>
                </title>
                <description>
                    <![CDATA[ Cloud computing has changed how organisations build, deploy, and scale applications. Businesses can launch new services in minutes, expand into new markets, and reduce the cost of maintaining physical ]]>
                </description>
                <link>https://www.freecodecamp.org/news/the-hidden-risks-lurking-in-your-cloud-infrastructure/</link>
                <guid isPermaLink="false">6a872fd36fe3503416ed20d8</guid>
                
                    <category>
                        <![CDATA[ Cloud Computing ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Cloud infrastructure ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Manish Shivanandhan ]]>
                </dc:creator>
                <pubDate>Thu, 20 Aug 2026 16:48:19 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/50037557-d27e-4f8c-bbb0-b9bc78582a5b.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Cloud computing has changed how organisations build, deploy, and scale applications. Businesses can launch new services in minutes, expand into new markets, and reduce the cost of maintaining physical infrastructure.</p>
<p>While these advantages have fueled rapid cloud adoption, they've also introduced new security challenges that many organisations fail to recognise until it's too late.</p>
<p>Unlike traditional data centers, cloud environments are dynamic. New virtual machines, databases, storage accounts, containers, and serverless functions can appear and disappear throughout the day.</p>
<p>As cloud infrastructure grows, so does the number of potential security gaps. Many of these vulnerabilities remain hidden because they stem from configuration errors rather than software flaws.</p>
<p>This is where cloud security posture management becomes essential. Instead of relying on periodic security reviews, organisations can continuously monitor their cloud environments, identify risks, and remediate issues before they become security incidents.</p>
<p>For each of the five risks below, I'll walk through one concrete example of how the problem actually shows up in a real environment, and the specific steps a team would take to fix it. The examples are deliberately provider-neutral, because the same patterns appear across every major cloud platform under different names.</p>
<p>Understanding these hidden risks is the first step toward building a stronger and more resilient cloud environment.</p>
<h2 id="heading-what-well-cover">What We'll Cover</h2>
<ul>
<li><p><a href="#heading-why-cloud-infrastructure-creates-new-security-challenges">Why Cloud Infrastructure Creates New Security Challenges</a></p>
</li>
<li><p><a href="#heading-hidden-risk-1-cloud-misconfigurations">Hidden Risk #1: Cloud Misconfigurations</a></p>
<ul>
<li><p><a href="#heading-what-this-looks-like-in-practice">What this looks like in practice</a></p>
</li>
<li><p><a href="#heading-how-to-mitigate-it">How to mitigate it</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-hidden-risk-2-excessive-permissions">Hidden Risk #2: Excessive Permissions</a></p>
<ul>
<li><p><a href="#heading-what-this-looks-like-in-practice">What this looks like in practice</a></p>
</li>
<li><p><a href="#heading-how-to-mitigate-it">How to mitigate it</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-hidden-risk-3-forgotten-cloud-resources">Hidden Risk #3: Forgotten Cloud Resources</a></p>
<ul>
<li><p><a href="#heading-what-this-looks-like-in-practice">What this looks like in practice</a></p>
</li>
<li><p><a href="#heading-how-to-mitigate-it">How to mitigate it</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-hidden-risk-4-compliance-drift">Hidden Risk #4: Compliance Drift</a></p>
<ul>
<li><p><a href="#heading-what-this-looks-like-in-practice">What this looks like in practice</a></p>
</li>
<li><p><a href="#heading-how-to-mitigate-it">How to mitigate it</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-hidden-risk-5-limited-visibility-across-multi-cloud-environments">Hidden Risk #5: Limited Visibility Across Multi-Cloud Environments</a></p>
<ul>
<li><p><a href="#heading-what-this-looks-like-in-practice">What this looks like in practice</a></p>
</li>
<li><p><a href="#heading-how-to-mitigate-it">How to mitigate it</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-why-manual-security-reviews-are-no-longer-enough">Why Manual Security Reviews Are No Longer Enough</a></p>
</li>
<li><p><a href="#heading-how-cloud-security-posture-management-strengthens-cloud-security">How Cloud Security Posture Management Strengthens Cloud Security</a></p>
</li>
<li><p><a href="#heading-building-a-stronger-cloud-security-strategy">Building a Stronger Cloud Security Strategy</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-why-cloud-infrastructure-creates-new-security-challenges">Why Cloud Infrastructure Creates New Security Challenges</h2>
<p>Modern cloud environments are far more complex than the on-premises infrastructure many security teams were built to protect. A single organisation may use Amazon Web Services (AWS), Microsoft Azure, and Google Cloud Platform (GCP) simultaneously. Each provider offers hundreds of services, each with its own security settings and access controls.</p>
<p>Development teams also create new cloud resources at an unprecedented pace. <a href="https://aws.amazon.com/what-is/iac/">Infrastructure as Code</a>, automated deployment pipelines, and container orchestration platforms enable provisioning entire environments in minutes. While this improves productivity, it also increases the likelihood of configuration errors.</p>
<p>The shared responsibility model adds another layer of complexity. Cloud providers secure the underlying infrastructure, but customers remain responsible for securing their applications, identities, storage, networks, and data. Many organisations mistakenly assume their cloud provider handles every aspect of security, leaving critical resources exposed.</p>
<h2 id="heading-hidden-risk-1-cloud-misconfigurations">Hidden Risk #1: Cloud Misconfigurations</h2>
<p>Misconfigurations remain one of the leading causes of cloud security incidents. A single incorrect setting can expose sensitive information to the public internet or give attackers access to critical systems.</p>
<p>Examples include publicly accessible storage buckets, databases that allow unrestricted network access, disabled encryption settings, and security groups with overly permissive firewall rules. These mistakes often occur during rapid deployments when security reviews are skipped or delayed.</p>
<p>The challenge is that cloud environments change constantly. Even if an environment is secure today, a new deployment tomorrow may introduce a vulnerability without anyone noticing.</p>
<h3 id="heading-what-this-looks-like-in-practice">What This Looks Like in Practice</h3>
<p>A product team needs to share a set of report images with an external design agency. The quickest path is to create an object storage bucket and set its access policy to allow read access from any identity, rather than setting up temporary signed URLs or a guest account. The share works, the agency delivers, and the ticket is closed.</p>
<p>Six weeks later, the same bucket is reused as the destination for a nightly export job. Nobody rechecks the access policy, because the bucket already exists and "works." The export contains customer names, email addresses, and internal order IDs, and it's now readable by anyone who can guess or enumerate the bucket name. Automated scanners that crawl for open buckets will typically find it within days.</p>
<p>Nothing was hacked here. No credential was stolen and no software was exploited. A setting that was reasonable for one purpose was silently inherited by another.</p>
<h3 id="heading-how-to-mitigate-it">How to Mitigate it</h3>
<ol>
<li><p><strong>Turn on the account-level public access block:</strong> Every major provider offers an org- or account-wide setting that overrides individual resource policies and prevents public exposure of storage. Enable it as the default, and treat any exception as a change that requires approval.</p>
</li>
<li><p><strong>Scan the infrastructure code before it's applied, not after:</strong> Add a policy-as-code check to your deployment pipeline so a template that grants public read on a bucket fails the build. This is where you catch the problem for free, before it exists in the environment.</p>
</li>
<li><p><strong>Replace public access with time-limited access:</strong> For genuine external sharing, use pre-signed URLs with an expiry measured in hours, or a scoped guest identity. This gives the same convenience without leaving a permanent hole.</p>
</li>
<li><p><strong>Alert on the change, not just the state:</strong> Configure your posture management tool to fire when a bucket's access policy changes to allow anonymous principals, and route it to the team that owns the resource. Detecting the drift within minutes is what separates a near miss from a breach notification.</p>
</li>
</ol>
<p>Cloud security posture management continuously scans cloud resources for configuration issues and alerts security teams when risks appear. Instead of waiting for a scheduled audit, organisations gain visibility into problems as they occur.</p>
<h2 id="heading-hidden-risk-2-excessive-permissions">Hidden Risk #2: Excessive Permissions</h2>
<p><a href="https://www.ibm.com/solutions/identity-access-management">Identity and Access Management (IAM)</a> is one of the most important parts of cloud security. Unfortunately, it's also one of the most common sources of risk.</p>
<p>Many organisations grant users, applications, or service accounts more permissions than they actually need. Over time, these excessive privileges accumulate as employees change roles, applications evolve, and temporary permissions become permanent.</p>
<p>If an attacker compromises an account with broad privileges, the damage can spread quickly across the cloud environment. Sensitive data, production systems, and administrative resources may all become accessible.</p>
<h3 id="heading-what-this-looks-like-in-practice">What This Looks Like in Practice</h3>
<p>A team sets up a deployment pipeline. The pipeline needs to create compute instances, update a container registry, and write to a couple of storage locations. Working out the exact permission set takes time, and the first three attempts fail with cryptic access-denied errors. Under deadline pressure, someone attaches the built-in administrator role to the pipeline's service identity "just to unblock it," with the intention of narrowing it later.</p>
<p>Later never comes. The pipeline now holds full administrative rights across the account, and its credentials live in a CI/CD system that dozens of engineers can configure. Anyone who can modify a build file, or who compromises a single developer's account on that CI system, can execute arbitrary commands with administrator privileges like read every database, create new identities, or disable logging.</p>
<p>The same pattern appears with people. An engineer moves from platform to frontend work, keeps the old production access "in case something breaks," and two years later has accumulated permissions spanning four teams they no longer belong to.</p>
<h3 id="heading-how-to-mitigate-it">How to Mitigate it</h3>
<ol>
<li><p><strong>Pull the last-accessed data before you touch anything:</strong> Every major provider records which services an identity has actually used and when. Start by listing every role with administrative privileges and check what it has genuinely called in the past 90 days. In most environments the real usage is a small fraction of what's granted.</p>
</li>
<li><p><strong>Generate a scoped policy from observed activity:</strong> Use the provider's policy-generation tooling (or your posture management platform) to produce a candidate policy based on that access history, then review it manually before applying. Applying the <a href="https://www.freecodecamp.org/news/principle-of-lease-privilege-meaning-cybersecurity/">principle of least privilege</a> is far easier when you start from evidence rather than guesswork.</p>
</li>
<li><p><strong>Roll it out in audit mode first:</strong> Apply the narrowed policy to a non-production copy of the pipeline, or run it alongside the permissive one with denial logging enabled, so you find the missing permissions without breaking deployments.</p>
</li>
<li><p><strong>Set an expiry on elevated access:</strong> Temporary administrative rights should be granted through a just-in-time mechanism that expires automatically, so the "narrow it later" step happens whether or not anyone remembers.</p>
</li>
<li><p><strong>Watch for privilege escalation paths, not just broad roles:</strong> A role that can't do much directly but can create identities, attach policies, or pass a privileged role to a new resource is effectively an administrator. Posture management tools flag these chains, which are almost impossible to spot by reading policies one at a time.</p>
</li>
</ol>
<h2 id="heading-hidden-risk-3-forgotten-cloud-resources">Hidden Risk #3: Forgotten Cloud Resources</h2>
<p>Cloud environments are rarely static. Development teams frequently create temporary virtual machines, test databases, storage accounts, and networking components while building new features. Once projects are completed, these resources are often forgotten rather than removed.</p>
<p>Although these abandoned resources may no longer serve a business purpose, they continue to consume cloud resources and may still contain sensitive information. Some may even remain connected to production networks.</p>
<p>These overlooked assets create unnecessary attack surfaces. Attackers often look for neglected systems because they're less likely to receive security updates or active monitoring.</p>
<h3 id="heading-what-this-looks-like-in-practice">What This Looks Like in Practice</h3>
<p>Ahead of a major release, a team spins up a load-testing environment: a handful of compute instances, a database restored from a production snapshot so the test data is realistic, and a permissive network rule opening administrative access from any address, because the testers are working from home and nobody wants to maintain an IP allowlist for a two-week project.</p>
<p>The release ships. The environment stays. A year on, those instances are running an operating system image that has missed four rounds of patches, the database still contains a year-old copy of real customer records, and the administrative port is still open to the internet. It doesn't appear on any architecture diagram, it isn't in the patching rota, and no alerting is configured against it, because it was never meant to exist for more than a fortnight.</p>
<p>This is one of the most attractive targets in a cloud estate: real data, no monitoring, and a known-vulnerable software version.</p>
<h3 id="heading-how-to-mitigate-it">How to Mitigate it</h3>
<ol>
<li><p><strong>Build an inventory that doesn't depend on memory:</strong> Query every region in every account for running compute, databases, storage, and snapshots, including the regions you believe you don't use, which is where forgotten resources most often hide. Deploying <a href="https://orca.security/resources/blog/open-source-application-security-tools/">application security tools</a> across both active and forgotten resources helps ensure vulnerabilities are caught before they can be exploited.</p>
</li>
<li><p><strong>Sort by last activity, then by exposure:</strong> Rank resources by how recently they saw network traffic or authentication, and cross-reference against public network reachability. A resource that's both idle and internet-facing is the highest-priority item on the list, every time.</p>
</li>
<li><p><strong>Require an owner tag at creation:</strong> Enforce a policy that rejects the creation of any resource without owner and environment tags. Untagged resources become the queue you work through, and the queue stops growing.</p>
</li>
<li><p><strong>Give non-production resources a default lifetime:</strong> Attach an expiry tag to anything created in a test or sandbox account, and run an automated job that stops resources past their date and deletes them after a grace period. Make deletion the default and continuation the thing that requires a decision.</p>
</li>
<li><p><strong>Treat production data in test environments as an incident:</strong> Restoring a production snapshot into a lower environment should trigger an alert. If realistic data is genuinely needed, mask it as part of the restore process.</p>
</li>
</ol>
<p>Continuous asset discovery is another important capability of cloud security posture management. By maintaining an up-to-date inventory of cloud resources, organisations can quickly identify unused assets and remove them before they become security liabilities.</p>
<h2 id="heading-hidden-risk-4-compliance-drift">Hidden Risk #4: Compliance Drift</h2>
<p>Meeting regulatory requirements isn't a one-time project. Standards such as ISO 27001, SOC 2, PCI DSS, HIPAA, and GDPR require organisations to maintain secure configurations over time.</p>
<p>As cloud environments evolve, once-compliant systems can gradually drift away from required security baselines. A disabled encryption setting, an open network port, or missing activity logs may create compliance violations without anyone noticing.</p>
<p>Manual compliance reviews often occur quarterly or annually, leaving long periods during which risks remain undetected.</p>
<h3 id="heading-what-this-looks-like-in-practice">What This Looks Like in Practice</h3>
<p>An organisation passes its SOC 2 audit. Part of the evidence is that API activity logging is enabled across all accounts and all regions, with logs written to a dedicated, immutable archive.</p>
<p>Four months later, an engineer investigating a surprise bill notices that log storage is one of the larger line items. Most of the volume comes from high-frequency read events in a development account. They disable logging in that account to cut costs. It's a sensible-looking decision made with incomplete context, and it's approved in a routine change ticket by someone who doesn't know the account is in the audit scope.</p>
<p>Nothing breaks and no alert fires. The gap surfaces eight months later during audit fieldwork, when the assessor asks for a continuous log record for the period and the organisation can't produce one. There's no way to backfill it. The finding stands, and the same gap means that if an incident had occurred in that account, there would be no record of who did what.</p>
<h3 id="heading-how-to-mitigate-it">How to Mitigate it</h3>
<ol>
<li><p><strong>Map each control to a machine-checkable rule:</strong> "Activity logging is enabled everywhere" becomes a specific check: a multi-region trail exists in every account, log file validation is on, and the destination has retention and object-lock configured. If a control can't be expressed as a check, it can't be monitored continuously.</p>
</li>
<li><p><strong>Make the critical controls structurally hard to disable:</strong> Deploy logging from the organisation level rather than per account, and apply a service control policy that denies the disable and delete actions to everyone except a break-glass identity. This turns a one-click mistake into a deliberate act.</p>
</li>
<li><p><strong>Run the checks continuously and alert on transitions:</strong> The valuable signal isn't the monthly compliance score, it's the moment a control moves from compliant to non-compliant. Route that event to a channel someone actually reads, with the account, the resource, and the identity that made the change.</p>
</li>
<li><p><strong>Add a compliance-scope check to your change process:</strong> Change tickets touching accounts in audit scope should surface that fact automatically, so the approver knows what they're approving.</p>
</li>
<li><p><strong>Keep the evidence as you go:</strong> Store the continuous check results with timestamps. Audit preparation then becomes an export rather than a scramble, and you find gaps months before an assessor does.</p>
</li>
</ol>
<p>Cloud security posture management continuously evaluates cloud environments against security frameworks and compliance standards. This allows organisations to detect violations early, simplify audit preparation, and reduce regulatory risk.</p>
<h2 id="heading-hidden-risk-5-limited-visibility-across-multi-cloud-environments">Hidden Risk #5: Limited Visibility Across Multi-Cloud Environments</h2>
<p>Many organisations now operate workloads across multiple cloud providers. Development teams may choose different platforms based on cost, performance, or specialised services.</p>
<p>While this approach offers flexibility, it also creates fragmented security visibility. Each cloud provider has its own monitoring tools, dashboards, and security controls. Security teams may struggle to understand the overall security posture of the organisation.</p>
<p>Without centralised visibility, important risks can easily go unnoticed. One cloud environment may enforce strong security policies while another contains exposed resources and outdated configurations.</p>
<h3 id="heading-what-this-looks-like-in-practice">What This Looks Like in Practice</h3>
<p>An organisation runs its core platform on one cloud provider, where the security team has spent two years building guardrails: preventative policies, automated scanning, and an on-call rota that responds to findings.</p>
<p>Meanwhile, the data science group adopts a second provider for its managed machine learning tooling. The account is opened on a corporate card, funded through a departmental budget, and never onboarded into the security programme. There's no reason for anyone to notice, no ticket crosses the security team's desk, because from the data team's perspective nothing about their work has changed.</p>
<p>That second environment ends up hosting training datasets derived from production records. Its storage defaults are different from the primary provider's, the equivalent of "block public access" isn't switched on, and the terminology doesn't map cleanly onto the checks the security team already wrote. The organisation's security dashboard shows a healthy posture, and it's accurate for the 70% of the estate it can see.</p>
<p>The failure isn't a weak control. It's that a whole environment is outside the boundary of the thing doing the measuring.</p>
<h3 id="heading-how-to-mitigate-it">How to Mitigate it</h3>
<ol>
<li><p><strong>Start with a billing reconciliation, not a technical scan:</strong> Ask finance for every cloud vendor the organisation pays, then compare that list against the accounts your security tooling monitors. Shadow cloud environments show up in expense reports long before they show up in a scanner.</p>
</li>
<li><p><strong>Write your baseline in terms of outcomes, not provider features:</strong> Define controls as "object storage must not be readable by anonymous principals" rather than naming a specific provider's setting. One statement then maps onto every platform, and the standard doesn't fragment.</p>
</li>
<li><p><strong>Normalise findings into a single queue:</strong> Pull results from each provider into one place with consistent severity scoring, so a critical exposure in the smallest environment ranks above a low-severity finding in the largest. Severity should reflect business impact, not which dashboard raised it.</p>
</li>
<li><p><strong>Make onboarding a condition of account creation:</strong> Require any new cloud account, on any provider, to be enrolled in central monitoring before it can hold production or production-derived data. Pair the requirement with a fast, low-friction path so teams aren't tempted to route around it.</p>
</li>
<li><p><strong>Re-run the discovery on a schedule:</strong> New accounts appear continuously. Repeat the billing reconciliation quarterly, because the environment you don't know about is by definition the one you'll never get an alert for.</p>
</li>
</ol>
<p>Cloud security posture management provides a unified view across multiple cloud providers, allowing organisations to monitor security consistently regardless of where workloads are deployed.</p>
<h2 id="heading-why-manual-security-reviews-are-no-longer-enough">Why Manual Security Reviews Are No Longer Enough</h2>
<p>Traditional security audits were designed for relatively stable infrastructure. Cloud environments operate very differently.</p>
<p>Infrastructure changes can occur hundreds or even thousands of times each day through automated deployment pipelines. New services are launched continuously, configurations are updated frequently, and development teams release software at a rapid pace.</p>
<p>Human reviewers can't keep up with this level of change. By the time a manual assessment is completed, the cloud environment may already look completely different.</p>
<p>Notice what the five examples above have in common. In every case, the environment was compliant at some point in the past, and a single reasonable-looking decision moved it out of compliance without producing an error, an outage, or a complaint. A bucket policy was reused. A role was widened to unblock a deployment. A test environment outlived its project. Logging was disabled to control cost. An account was opened outside the security boundary.</p>
<p>None of these would be caught by a quarterly review, because by the time a reviewer arrives, the change is months old and looks like part of the furniture. Automation has become essential. Continuous monitoring allows organisations to identify new risks immediately instead of discovering them weeks or months later.</p>
<h2 id="heading-how-cloud-security-posture-management-strengthens-cloud-security">How Cloud Security Posture Management Strengthens Cloud Security</h2>
<p>Cloud security posture management provides continuous visibility into cloud environments by automatically evaluating resources against security best practices and organisational policies.</p>
<p>Rather than replacing existing security tools, it complements them by focusing on configuration security, governance, compliance, and risk reduction.</p>
<p>A modern cloud security posture management platform can discover cloud assets, monitor configuration changes, identify excessive permissions, detect publicly exposed resources, validate compliance requirements, and prioritise security findings based on business impact.</p>
<p>Many platforms also support automated remediation. Instead of simply reporting an issue, they can trigger workflows that correct insecure configurations or notify the appropriate teams before vulnerabilities are exploited.</p>
<p>A word of caution on automated remediation: start it in report-only mode. A rule that automatically closes public access is exactly right for a storage bucket holding internal exports and exactly wrong for one serving a public website's static assets. Run new rules in observation mode for a few weeks, review what they would have changed, and only then let them act , beginning with the resource types where a false positive is cheap.</p>
<p>This proactive approach allows organisations to reduce risk without slowing down software development.</p>
<h2 id="heading-building-a-stronger-cloud-security-strategy">Building a Stronger Cloud Security Strategy</h2>
<p>Technology alone can't eliminate cloud security risks. Organisations must also establish strong governance processes and promote security awareness across development, operations, and security teams.</p>
<p>Security should become part of the software development lifecycle rather than an activity performed after deployment. Infrastructure templates should follow secure defaults, access permissions should be reviewed regularly, and cloud resources should be continuously monitored throughout their lifecycle.</p>
<p>If you're starting from scratch, the sequence that tends to work is: inventory first, since you can't secure what you can't see; then public exposure, because that's where the shortest path from mistake to breach runs; then identity, because over-permissioned roles determine how far an attacker gets once inside; and finally compliance mapping, which is far easier once the first three are under control.</p>
<p>Regular security training also helps reduce human error. Developers who understand cloud security best practices are less likely to introduce risky configurations during deployment. It's worth noting that in every example in this article, the person who introduced the risk was doing their job competently under normal constraints. Training works best when it's framed that way, and when the secure path is also the convenient one.</p>
<p>Combining secure development practices with cloud security posture management creates multiple layers of protection that significantly reduce the likelihood of security incidents.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Cloud infrastructure offers tremendous flexibility, but it also introduces risks that are often difficult to detect through traditional security practices. Misconfigured resources, excessive permissions, forgotten assets, compliance drift, and fragmented visibility can quietly weaken an organisation's security posture without triggering immediate alarms.</p>
<p>As cloud environments continue to grow in size and complexity, continuous monitoring has become a necessity rather than a luxury. Cloud security posture management enables organisations to identify vulnerabilities, maintain compliance, and respond to security risks before they lead to costly breaches.</p>
<p>The organisations that succeed in the cloud aren't those that eliminate every possible risk. They're the ones who continuously understand their environment, detect changes as they happen, and act quickly to maintain a secure cloud infrastructure.</p>
<p>In a landscape where new resources are created every day, maintaining visibility into your cloud security posture is one of the most effective ways to stay ahead of evolving threats.</p>
<p>Hope you enjoyed this article. You can <a href="https://linkedin.com/in/manishmshiva">connect with me on LinkedIn</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ CSRF from Scratch: Browser Mechanics, Attacks, and Spring Security Implementation [Full Handbook] ]]>
                </title>
                <description>
                    <![CDATA[ If you've ever built a web application or configured Spring Security, you've almost certainly encountered Cross-Site Request Forgery (CSRF). In my previous guide, How OAuth 2.0 Works: A Practical Guid ]]>
                </description>
                <link>https://www.freecodecamp.org/news/csrf-from-scratch-browser-mechanics-attacks-and-spring-security-implementation-handbook/</link>
                <guid isPermaLink="false">6a74fe284ef5707f2879423d</guid>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ csrf ]]>
                    </category>
                
                    <category>
                        <![CDATA[ spring-boot ]]>
                    </category>
                
                    <category>
                        <![CDATA[ spring security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Java ]]>
                    </category>
                
                    <category>
                        <![CDATA[ authentication ]]>
                    </category>
                
                    <category>
                        <![CDATA[ cookies ]]>
                    </category>
                
                    <category>
                        <![CDATA[ handbook ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Ashutosh Krishna ]]>
                </dc:creator>
                <pubDate>Thu, 06 Aug 2026 21:35:36 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/20e903c5-9011-4f14-b714-974e32d43f3c.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>If you've ever built a web application or configured Spring Security, you've almost certainly encountered Cross-Site Request Forgery (CSRF).</p>
<p>In my previous guide, <a href="https://medium.com/@ashutoshkrris/how-oauth-2-0-works-a-practical-guide-for-backend-developers-630977209476"><strong>How OAuth 2.0 Works: A Practical Guide for Backend Developers</strong></a>, I briefly touched on the mysterious <code>state</code> parameter and noted that its core purpose is protecting authorization flows against CSRF attacks.</p>
<p>At the time, we treated CSRF as a quick prerequisite concept. Today, we're taking a much deeper dive.</p>
<p>Perhaps you were building a REST API in Spring Boot, ran into unexpected HTTP 403 Forbidden errors on every <code>POST</code> request, and "fixed" it by adding <code>.csrf(csrf -&gt; csrf.disable())</code> to your Security Filter Chain.</p>
<p>Most tutorials treat CSRF as a checkbox item or a framework toggle. They immediately jump to code:</p>
<pre><code class="language-java">// What most tutorials show on line 1:
http.csrf(Customizer.withDefaults());
</code></pre>
<p>Starting with framework configuration hides how web security actually operates. Spring Security doesn't invent security rules out of thin air. It responds to the fundamental mechanics of web browsers, HTTP protocols, and cookies.</p>
<p>In this handbook, we'll take a bottom-up, first-principles approach. We won't talk about Spring Security until we've thoroughly explored browsers, HTTP headers, session management, and the underlying mechanics of Cross-Site Request Forgery.</p>
<p>By the end of this guide, you'll understand:</p>
<ul>
<li><p>Why browsers automatically attach credentials to outgoing requests.</p>
</li>
<li><p>Why that automatic behavior creates a fundamental vulnerability.</p>
</li>
<li><p>Why attackers never need to steal or read your cookies to exploit CSRF.</p>
</li>
<li><p>Why Same Origin Policy (SOP) and CORS don't prevent CSRF.</p>
</li>
<li><p>How modern defenses, from CSRF Tokens to <code>SameSite</code> cookies, work under the hood.</p>
</li>
<li><p>How Spring Security implements these defenses internally and how to configure them effectively.</p>
</li>
</ul>
<p>Let’s begin by stripping away frameworks and looking at how the web actually works.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-the-problem-before-csrf">The Problem Before CSRF</a></p>
</li>
<li><p><a href="#heading-why-browsers-automatically-send-cookies">Why Browsers Automatically Send Cookies</a></p>
</li>
<li><p><a href="#heading-when-automatic-cookies-become-dangerous">When Automatic Cookies Become Dangerous</a></p>
</li>
<li><p><a href="#heading-visualize-the-attack">Visualize the Attack</a></p>
</li>
<li><p><a href="#heading-why-the-browser-isnt-broken">Why the Browser Isn't Broken</a></p>
</li>
<li><p><a href="#heading-same-origin-policy-sop">Same Origin Policy (SOP)</a></p>
</li>
<li><p><a href="#heading-why-cors-does-not-prevent-csrf">Why CORS Does NOT Prevent CSRF</a></p>
</li>
<li><p><a href="#heading-safe-methods-and-state-mutation">Safe Methods and State Mutation</a></p>
</li>
<li><p><a href="#heading-csrf-tokens-synchronizer-token-pattern">CSRF Tokens (Synchronizer Token Pattern)</a></p>
</li>
<li><p><a href="#heading-double-submit-cookie-pattern">Double Submit Cookie Pattern</a></p>
</li>
<li><p><a href="#heading-samesite-cookies">SameSite Cookies</a></p>
</li>
<li><p><a href="#heading-origin-and-referer-headers">Origin and Referer Headers</a></p>
</li>
<li><p><a href="#heading-jwt-and-csrf-the-token-storage-dilemma">JWT and CSRF: The Token Storage Dilemma</a></p>
</li>
<li><p><a href="#heading-spring-security-csrf-internals">Spring Security CSRF Internals</a></p>
</li>
<li><p><a href="#heading-implement-csrf-protection-yourself">Implement CSRF Protection Yourself</a></p>
</li>
<li><p><a href="#heading-testing-csrf-protections">Testing CSRF Protections</a></p>
</li>
<li><p><a href="#heading-common-misconceptions">Common Misconceptions</a></p>
</li>
<li><p><a href="#heading-production-best-practices-checklist">Production Best Practices Checklist</a></p>
</li>
<li><p><a href="#heading-final-summary-amp-defense-matrix">Final Summary &amp; Defense Matrix</a></p>
</li>
</ul>
<h2 id="heading-the-problem-before-csrf">The Problem Before CSRF</h2>
<p>To understand security, we must first understand state.</p>
<p>The Hypertext Transfer Protocol (HTTP) is inherently <strong>stateless</strong>. This means that if Alice sends an HTTP request to <code>travelbuddy.com</code> (our example) at 10:00 AM, and sends another HTTP request to <code>travelbuddy.com</code> at 10:01 AM, the server treats those two requests as completely isolated, unrelated events.</p>
<img src="https://cdn.hashnode.com/uploads/covers/61c1acb4a90dea775da8262b/c5c24ee5-450d-4252-8e79-3744f9814fbd.png" alt="Sequence diagram showing Alice’s browser making a successful GET request to the TravelBuddy Server, followed 1 minute later by a second GET request that returns a 401 Unauthorized error." style="display: block;" width="1071" height="860" loading="lazy">

<p>Without a mechanism to remember Alice between requests, Alice would have to send her username and password inside <em>every single HTTP request</em> she makes. That would be horrific for both user experience and performance.</p>
<p>Before session mechanisms were standard, developers tried passing credentials via query parameters or basic authentication headers on every click. This led to credential exposure in server logs, browser histories, and URL shares.</p>
<h3 id="heading-how-do-sessions-and-cookies-solve-this">How Do Sessions and Cookies Solve This?</h3>
<p>To solve this, web engineers introduced the concept of <strong>Server-Side Sessions</strong> and <strong>HTTP Cookies</strong>.</p>
<p>When Alice logs into <code>TravelBuddy</code> by sending her username and password via a POST request to <code>https://travelbuddy.com/login</code>, the server verifies her credentials. Instead of asking Alice to log in again on the next page, the server creates a <strong>Session</strong> in its memory (or in a database/Redis cache) and assigns it a unique, unpredictable identifier: a <strong>Session ID</strong>.</p>
<p>The server then sends this Session ID back to Alice’s browser using a special HTTP response header: <code>Set-Cookie</code>.</p>
<pre><code class="language-plaintext">HTTP/1.1 200 OK
Content-Type: text/html
Set-Cookie: JSESSIONID=abc123xyz789; Path=/; Secure; HttpOnly
</code></pre>
<p>When Alice’s browser receives this response, it sees the <code>Set-Cookie</code> header. It extracts <code>JSESSIONID=abc123xyz789</code> and stores it inside its internal storage unit: the <strong>Browser Cookie Jar</strong>.</p>
<p>Now, Alice is "logged in". The server remembers her via that session record, and the browser holds the key (<code>JSESSIONID</code>) to that session.</p>
<h2 id="heading-why-browsers-automatically-send-cookies">Why Browsers Automatically Send Cookies</h2>
<p>Now we arrive at the pivotal design choice made in the early days of the web.</p>
<p>Once the browser stores <code>JSESSIONID=abc123xyz789</code> in its cookie jar for the domain <code>travelbuddy.com</code>, how does that cookie get sent back to the server on subsequent requests?</p>
<p>Does the developer have to write custom JavaScript to attach the cookie? <strong>No.</strong></p>
<p>Browsers are explicitly designed to handle cookie management <strong>automatically</strong>.</p>
<h3 id="heading-the-request-lifecycle-and-automatic-cookie-attachment">The Request Lifecycle and Automatic Cookie Attachment</h3>
<p>Every time Alice's browser prepares an HTTP request to <code>https://travelbuddy.com</code> (whether caused by Alice clicking a link, submitting an HTML form, or JavaScript triggering a <code>fetch()</code> call), the browser follows this exact process:</p>
<ol>
<li><p><strong>URL Inspection:</strong> The browser examines the destination URL (for example, <code>https://travelbuddy.com/api/connections</code>).</p>
</li>
<li><p><strong>Cookie Jar Lookup:</strong> The browser scans its cookie jar for any stored cookies whose domain and path match <code>travelbuddy.com</code>.</p>
</li>
<li><p><strong>Validation Check:</strong> It verifies if the cookie has expired, and if flags like <code>Secure</code> (requires HTTPS) are respected.</p>
</li>
<li><p><strong>Header Injection:</strong> If valid cookies match, the browser automatically injects a <code>Cookie</code> header into the outgoing HTTP request payload.</p>
</li>
</ol>
<p>Here's what the outgoing request looks like as it leaves Alice's machine:</p>
<pre><code class="language-shell">POST /api/connections/add HTTP/1.1
Host: travelbuddy.com
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)
Accept: text/html,application/xhtml+xml
Cookie: JSESSIONID=abc123xyz789
Content-Type: application/x-www-form-urlencoded

service=SkyScanner
</code></pre>
<p>Notice something critical: <strong>Neither Alice nor any custom frontend JavaScript explicitly attached</strong> <code>Cookie: JSESSIONID=abc123xyz789</code><strong>.</strong></p>
<p>The browser's internal engine attached it automatically before sending the byte stream across the network. From the server's perspective, receiving <code>Cookie: JSESSIONID=abc123xyz789</code> is proof that the request originated from an authenticated session belonging to Alice.</p>
<p>This automatic behavior is convenient. It makes web browsing seamless across page reloads and link navigation. But as we'll soon see, this convenience leaves a backdoor wide open.</p>
<h2 id="heading-when-automatic-cookies-become-dangerous">When Automatic Cookies Become Dangerous</h2>
<p>Is automatic cookie inclusion a vulnerability by itself?</p>
<p><strong>No.</strong> If Alice only visits <code>travelbuddy.com</code>, automatic cookie inclusion works exactly as intended.</p>
<p>The vulnerability emerges because of a simple web reality: <strong>Alice visits multiple websites in the same browser session.</strong></p>
<h3 id="heading-enter-evilcom">Enter <code>evil.com</code></h3>
<p>Suppose Alice is logged into <code>TravelBuddy</code> in Tab 1. Her session cookie (<code>JSESSIONID=abc123xyz789</code>) sits safely inside her browser's cookie jar for <code>travelbuddy.com</code>.</p>
<p>In Tab 2, Alice visits an unrelated website: <code>https://evil.com</code> (perhaps she clicked a link in a phishing email or a forum post).</p>
<p><code>evil.com</code> is controlled by an attacker. The attacker knows that <code>TravelBuddy</code> has a feature located at <code>POST</code> <code>[https://travelbuddy.com/api/connections/add</code> that connects third-party services. The attacker wants to trick Alice into connecting the attacker's malicious service to her account.</p>
<p>The attacker embeds the following hidden HTML form inside the HTML page served by <code>evil.com</code>:</p>
<pre><code class="language-html">&lt;!-- Hosted on https://evil.com/win-a-car.html --&gt;
&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;body&gt;
  &lt;h1&gt;You won a free trip! Click below to claim.&lt;/h1&gt;
  
  &lt;!-- Hidden Form targeting TravelBuddy --&gt;
  &lt;form id="maliciousForm" action="https://travelbuddy.com/api/connections/add" method="POST"&gt;
    &lt;input type="hidden" name="service" value="MaliciousAttackerService" /&gt;
  &lt;/form&gt;

  &lt;script&gt;
    // Automatically submit the form as soon as the page loads
    document.getElementById('maliciousForm').submit();
  &lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;
</code></pre>
<h3 id="heading-walkthrough-of-the-attack-execution">Walkthrough of the Attack Execution</h3>
<p>Let's trace step-by-step what happens when Alice opens <code>https://evil.com/win-a-car.html</code>:</p>
<ol>
<li><p>Alice's browser fetches and parses HTML from <code>evil.com</code>.</p>
</li>
<li><p>The browser encounters the <code>&lt;script&gt;</code> tag and executes <code>document.getElementById('maliciousForm').submit()</code>.</p>
</li>
<li><p>The browser prepares an outgoing <code>POST</code> request targeting <code>https://travelbuddy.com/api/connections/add</code>.</p>
</li>
<li><p>The browser looks at the target destination: <code>travelbuddy.com</code>.</p>
</li>
<li><p>The browser checks its Cookie Jar: <em>"Do I have any active cookies for</em> <code>travelbuddy.com</code><em>?"</em></p>
</li>
<li><p><strong>Yes!</strong> It finds <code>JSESSIONID=abc123xyz789</code> (Alice's active session cookie from Tab 1).</p>
</li>
<li><p>The browser automatically injects <code>Cookie: JSESSIONID=abc123xyz789</code> into the outgoing request payload heading to <code>travelbuddy.com</code>.</p>
</li>
<li><p>The request lands on the <code>TravelBuddy</code> Spring Boot backend server.</p>
</li>
</ol>
<h3 id="heading-the-servers-perspective">The Server's Perspective</h3>
<p>Here's what the <code>TravelBuddy</code> backend sees when processing the request:</p>
<pre><code class="language-shell">POST /api/connections/add HTTP/1.1
Host: travelbuddy.com
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)
Content-Type: application/x-www-form-urlencoded
Cookie: JSESSIONID=abc123xyz789

service=MaliciousAttackerService
</code></pre>
<p>The <code>TravelBuddy</code> server checks the <code>Cookie</code> header. It validates <code>JSESSIONID=abc123xyz789</code> against its session store. The session is valid: it belongs to Alice!</p>
<p>The server assumes: <em>"Alice sent a POST request to add</em> <code>MaliciousAttackerService</code><em>. She is authenticated, so I will grant this request."</em></p>
<p>The server updates Alice's account state. <code>MaliciousAttackerService</code> is now connected to her profile.</p>
<h3 id="heading-the-core-realization-of-csrf">The Core Realization of CSRF</h3>
<p>Take a step back and examine what just happened:</p>
<ol>
<li><p><strong>The attacker NEVER saw or stole Alice’s session cookie.</strong> The attacker on <code>evil.com</code> can't read cookies belonging to <code>travelbuddy.com</code> due to browser isolation rules.</p>
</li>
<li><p><strong>The attacker did NOT break encryption.</strong> HTTPS was active the entire time.</p>
</li>
<li><p><strong>The attacker simply induced Alice's browser to make a request.</strong> The browser, faithfully executing its automatic cookie attachment rules, provided the credentials on behalf of the attacker. You could say the attacker got caught with their hand in Alice's cookie jar!</p>
</li>
</ol>
<p>This is <strong>Cross-Site Request Forgery in action</strong>: An attacker tricks a victim's browser into executing an unwanted, state-changing HTTP request to a trusted site where the victim is currently authenticated.</p>
<h2 id="heading-visualize-the-attack">Visualize the Attack</h2>
<p>Visualizing the interaction between Alice, the browser, <code>evil.com</code>, and <code>TravelBuddy</code> makes the underlying request flow clear.</p>
<h3 id="heading-1-the-complete-csrf-sequence">1. The Complete CSRF Sequence</h3>
<img src="https://cdn.hashnode.com/uploads/covers/61c1acb4a90dea775da8262b/0431c42a-815b-482c-900e-7985c3f5ace1.png" alt="Sequence diagram illustrating a Cross-Site Request Forgery (CSRF) attack where an attacker site (evil.com) uses an auto-submitting form to trick a logged-in user’s browser into sending an authenticated request to travelbuddy.com." style="display: block;" width="2614" height="2116" loading="lazy">

<p>The attack unfolds across three distinct phases involving four main actors: Alice, her web browser, the TravelBuddy backend server, and the attacker site running on <code>evil.com</code>.</p>
<p>In the first phase, Alice authenticates with TravelBuddy. She submits her login credentials through her browser, which sends a POST request to the TravelBuddy backend. The backend verifies her credentials and responds with an HTTP 200 OK status alongside a <code>Set-Cookie</code> header containing <code>JSESSIONID=abc123xyz</code>.</p>
<p>Upon receiving this response, Alice's browser automatically saves this session identifier inside its cookie jar for the <code>travelbuddy.com</code> domain.</p>
<p>In the second phase, the attacker sets a trap. While keeping her TravelBuddy tab active, Alice opens a second browser tab and visits <code>evil.com</code>. Her browser requests the page <code>win-a-car.html</code> from <code>evil.com</code>. In response, <code>evil.com</code> serves an HTML document containing an invisible form targeting TravelBuddy, paired with an embedded JavaScript script designed to trigger immediately upon loading.</p>
<p>In the final phase, the attack executes automatically. The malicious JavaScript on <code>evil.com</code> calls <code>form.submit()</code>, commanding the browser to send a POST request to <code>https://travelbuddy.com/api/connections/add</code>.</p>
<p>Before sending the request across the network, the browser checks its cookie jar for any cookies matching <code>travelbuddy.com</code>. It finds Alice's active session cookie and automatically attaches <code>Cookie: JSESSIONID=abc123xyz</code> to the outgoing request payload. The TravelBuddy server receives the request, inspects the valid session cookie, assumes Alice intended to perform this action, and attaches the attacker's service to her account.</p>
<h3 id="heading-2-browser-decision-tree-during-outgoing-request">2. Browser Decision Tree during Outgoing Request</h3>
<p>When any request is fired, the browser follows a decision path regarding cookie attachment:</p>
<img src="https://cdn.hashnode.com/uploads/covers/61c1acb4a90dea775da8262b/59876902-2a43-4082-81f8-83e2c198e0c6.png" alt="Flowchart showing how a web browser automatically checks its Cookie Jar and attaches valid cookies to an outgoing HTTP request targeting travelbuddy.com." style="display: block;" width="1168" height="2635" loading="lazy">

<p>This diagram outlines the automatic evaluation loop executed by a browser whenever an HTTP request is triggered from any tab or script.</p>
<p>The process begins as soon as an outgoing HTTP request is initiated. The browser first inspects the target URL to extract the destination domain, such as <code>travelbuddy.com</code>. Once the domain is identified, the browser queries its internal cookie storage to check whether any cookies are mapped to that target domain. If no matching cookies exist, the browser immediately skips credential attachment and dispatches the raw HTTP request across the network.</p>
<p>If matching cookies are found, the browser evaluates their validity. It checks whether the cookies have expired, whether the request path matches the path defined in the cookie, and whether security constraints like the <code>Secure</code> HTTPS flag are satisfied. If any validation check fails, the cookie is discarded, and the request proceeds without credentials. But if the cookies are valid and active, the browser constructs a <code>Cookie</code> header containing the stored session key and attaches it to the outgoing HTTP request payload before dispatching it across the network to the server.</p>
<h3 id="heading-3-session-and-cookie-lifecycle-state-diagram">3. Session and Cookie Lifecycle State Diagram</h3>
<img src="https://cdn.hashnode.com/uploads/covers/61c1acb4a90dea775da8262b/b244543c-2ad6-455c-949d-eefe219eb4a0.png" alt="State diagram showing a user transitioning from an unauthenticated state to an authenticated state with automatic cookie management, and how maintaining an active session leaves the application vulnerable to CSRF when visiting a malicious site." style="display: block;" width="902" height="2096" loading="lazy">

<p>This state diagram tracks how a user moves between secure, authenticated, and vulnerable conditions during a web session.</p>
<p>When a user first opens their web browser, they begin in an unauthenticated state with no cookies stored for the target application. Submitting valid credentials via a login form transitions the user into an authenticated state. Inside this authenticated state, the server issues a <code>Set-Cookie</code> header, causing the browser to save the session ID in its cookie storage. For every subsequent request directed to that application, the browser automatically attaches the cookie while keeping the user logged in.</p>
<p>A vulnerability window opens when an authenticated user opens a second tab and navigates to an untrusted website while their application session remains active. This action shifts the browser context into a state vulnerable to Cross-Site Request Forgery. If the untrusted site fires a cross-site request back to the original application, the browser's automatic cookie attachment mechanism triggers, executing an unauthorized state change on the server. The cycle ends only when the user logs out or the server session expires, returning the client to the initial unauthenticated state.</p>
<h2 id="heading-why-the-browser-isnt-broken">Why the Browser Isn't Broken</h2>
<p>When developers first grasp CSRF, their immediate reaction is often: <em>"This is a terrible browser flaw! Why don't browser vendors fix this by disabling automatic cookie sending entirely?"</em></p>
<p>To understand why browsers behave this way, we must look at <strong>Web Compatibility</strong> and a concept known in security engineering as <strong>Ambient Authority</strong>.</p>
<h3 id="heading-the-principle-of-ambient-authority">The Principle of Ambient Authority</h3>
<p>When a system automatically applies a user's identity or credentials to every action without requiring explicit user intent for <em>that specific action</em>, the system is using <strong>ambient authority</strong>.</p>
<p>HTTP cookies are an ambient credential. If you're logged in, every request carrying a destination URL automatically includes your credential.</p>
<h3 id="heading-why-browser-vendors-dont-just-fix-it">Why Browser Vendors Don't Just "Fix" It</h3>
<p>The World Wide Web was created as a web of interconnected hypermedia documents. Cross-site interactions are a fundamental design feature of the web, not an accidental bug:</p>
<ul>
<li><p><strong>Images and assets:</strong> When <code>news.com</code> embeds an image hosted on <code>cdn.com</code>, your browser makes a cross-site request to <code>cdn.com</code>.</p>
</li>
<li><p><strong>Cross-site form submissions:</strong> In the early web (and still today), paying with PayPal meant an HTML form on <code>e-commerce.com</code> submitted data directly to <code>paypal.com</code>.</p>
</li>
<li><p><strong>Hyperlinks:</strong> Clicking a link on <code>google.com</code> takes you to <code>wikipedia.org</code> via a cross-site GET request.</p>
</li>
</ul>
<p>If browsers suddenly stopped attaching cookies to cross-site requests by default, <strong>millions of legacy websites built over three decades would break instantly.</strong> Users would be logged out whenever they clicked a link from an email, a search engine, or a social media site.</p>
<p>Browser vendors prioritize backward compatibility. Rather than removing cross-site capabilities, they introduced configurable security boundaries that developers can opt into.</p>
<p>To understand these boundaries, we must first look at the most fundamental browser security model: the <strong>Same Origin Policy</strong>.</p>
<h2 id="heading-same-origin-policy-sop">Same Origin Policy (SOP)</h2>
<p>Many developers assume: <em>"Doesn't the Same Origin Policy block cross-site requests?"</em></p>
<p>This is one of the most common misunderstandings in web development. Let's clarify what the Same Origin Policy actually is and what it does.</p>
<h3 id="heading-defining-an-origin">Defining an Origin</h3>
<p>An <strong>Origin</strong> in web security is defined by three components:</p>
<ol>
<li><p><strong>Scheme</strong> (Protocol, for example, <code>http</code> vs <code>https</code>)</p>
</li>
<li><p><strong>Host</strong> (Domain, for example, <code>travelbuddy.com</code>)</p>
</li>
<li><p><strong>Port</strong> (for example, <code>:80</code>, <code>:443</code>, <code>:8080</code>)</p>
</li>
</ol>
<p>Two URLs have the <strong>Same Origin</strong> if and only if all three components match exactly.</p>
<table>
<thead>
<tr>
<th>URL 1</th>
<th>URL 2</th>
<th>Same Origin?</th>
<th>Reason</th>
</tr>
</thead>
<tbody><tr>
<td><code>https://travelbuddy.com/page1</code></td>
<td><code>https://travelbuddy.com/page2</code></td>
<td><strong>YES</strong></td>
<td>Scheme, host, and port match.</td>
</tr>
<tr>
<td><code>http://travelbuddy.com/page1</code></td>
<td><code>https://travelbuddy.com/page1</code></td>
<td><strong>NO</strong></td>
<td>Scheme differs (<code>http</code> vs <code>https</code>).</td>
</tr>
<tr>
<td><code>https://travelbuddy.com/page1</code></td>
<td><code>https://api.travelbuddy.com/page1</code></td>
<td><strong>NO</strong></td>
<td>Host differs (<code>travelbuddy.com</code> vs <code>api.travelbuddy.com</code>).</td>
</tr>
<tr>
<td><code>https://travelbuddy.com:8080</code></td>
<td><code>https://travelbuddy.com:9090</code></td>
<td><strong>NO</strong></td>
<td>Port differs (<code>8080</code> vs <code>9090</code>).</td>
</tr>
</tbody></table>
<h3 id="heading-what-sop-protects-vs-what-sop-allows">What SOP Protects vs. What SOP Allows</h3>
<p>The Same Origin Policy governs how scripts running on one origin can interact with resources on another origin.</p>
<p><strong>The SOP Golden Rule:</strong> Same Origin Policy restricts scripts from <strong>READING</strong> responses from another origin. Same Origin Policy generally <strong>DOES NOT PREVENT</strong> scripts or HTML from <strong>SENDING</strong> requests to another origin.</p>
<p>Let's emphasize this distinction:</p>
<p>Sending a request: <code>evil.com</code> can create an HTML form like this: <code>&lt;form action="https://travelbuddy.com/api/delete" method="POST"&gt;</code>. When the form is submitted, the browser will send the request to <code>travelbuddy.com</code>. The backend will process the request and mutate the database state.</p>
<p>Reading the response: JavaScript running on <code>evil.com</code> attempts to inspect the HTTP response body returned by <code>travelbuddy.com</code>. The browser <strong>blocks</strong> JavaScript from reading that data because <code>evil.com</code> and <code>travelbuddy.com</code> are different origins.</p>
<img src="https://cdn.hashnode.com/uploads/covers/61c1acb4a90dea775da8262b/dd549ea3-7d83-494a-90b0-9a7a3c0a91b8.png" alt="Sequence diagram showing how the Browser’s Same-Origin Policy (SOP) blocks malicious JavaScript on evil.com from reading a cross-origin HTTP response from travelbuddy.com, even though the server executed the request." style="display: block;" width="1508" height="816" loading="lazy">

<p>Notice the flaw relative to CSRF: <strong>CSRF is an attack on state mutation, not data retrieval.</strong></p>
<p>The attacker on <code>evil.com</code> doesn't care to read the response payload returning from <code>travelbuddy.com</code>. Their goal was simply to trigger the action on the server. Because SOP permits request execution and only blocks response reading, <strong>Same Origin Policy alone offers zero protection against CSRF.</strong></p>
<h2 id="heading-why-cors-does-not-prevent-csrf">Why CORS Does NOT Prevent CSRF</h2>
<p>This brings us to another major source of confusion: <strong>Cross-Origin Resource Sharing (CORS)</strong>.</p>
<p>In developer forums, when someone experiences a CSRF issue or a cross-site issue, a common suggestion is: <em>"Just configure CORS properly on your backend!"</em></p>
<p>Let's state this as clearly as possible: CORS does <strong>NOT</strong> prevent CSRF attacks. In fact, CORS is designed to <em>relax</em> Same Origin Policy restrictions, not add new security restrictions.</p>
<h3 id="heading-reading-vs-sending-revisited">Reading vs. Sending Revisited</h3>
<p>Remember: SOP blocks cross-origin reading by default.</p>
<p>CORS (Cross-Origin Resource Sharing) is a mechanism that allows a server (for example, <code>travelbuddy.com</code>) to explicitly tell the browser: <em>"I trust JavaScript running on</em> <code>trusted-partner.com</code><em>. You may allow</em> <code>trusted-partner.com</code> <em>to read my responses."</em></p>
<p>CORS is an opt-in mechanism to <strong>allow cross-origin reading</strong>. Disabling or improperly configuring CORS doesn't stop a browser from sending a forged request.</p>
<h3 id="heading-simple-requests-vs-preflighted-requests">Simple Requests vs. Preflighted Requests</h3>
<p>To understand why CORS fails to stop CSRF, we must examine how browsers handle cross-origin HTTP requests under CORS rules. Browsers divide cross-origin requests into two categories:</p>
<ol>
<li><p>Simple Requests</p>
</li>
<li><p>Preflighted Requests</p>
</li>
</ol>
<h4 id="heading-1-simple-requests">1. Simple Requests</h4>
<p>A request is considered a <strong>Simple Request</strong> if it satisfies all of the following:</p>
<ul>
<li><p>Uses HTTP methods: <code>GET</code>, <code>HEAD</code>, or <code>POST</code>.</p>
</li>
<li><p>Uses standard browser Content-Types: <code>application/x-www-form-urlencoded</code>, <code>multipart/form-data</code>, or <code>text/plain</code>.</p>
</li>
<li><p>Doesn't set custom HTTP headers (like <code>X-Requested-With</code> or <code>Authorization</code>).</p>
</li>
</ul>
<p>When a browser encounters a <strong>Simple Request</strong> (such as a standard HTML form POST), it sends the request immediately to the target server.</p>
<img src="https://cdn.hashnode.com/uploads/covers/61c1acb4a90dea775da8262b/a0f9324c-2d7b-4495-a519-c95f5c959be4.png" alt="Sequence diagram illustrating why CORS does not prevent CSRF attacks on simple requests, showing that travelbuddy.com executes a state-changing POST request before the browser blocks evil.com from reading the response." style="display: block;" width="1877" height="1184" loading="lazy">

<p>As the diagram shows, the server executes the SQL <code>UPDATE</code> or <code>INSERT</code> statement the moment the request arrives. By the time the browser evaluates CORS headers on the returning response, the state mutation on the server has already happened.</p>
<h4 id="heading-2-preflighted-requests">2. Preflighted Requests</h4>
<p>If a request uses non-standard methods (<code>PUT</code>, <code>DELETE</code>) or non-standard content types (<code>application/json</code>), or custom headers, the browser first sends an <code>OPTIONS</code> request called a <strong>Preflight Request</strong>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/61c1acb4a90dea775da8262b/9ec33169-fa8b-48e6-b59f-7365f435ca33.png" alt="Sequence diagram demonstrating how CORS preflight requests (OPTIONS) prevent CSRF attacks by stopping non-simple requests (like JSON payloads) before the actual POST request is sent to travelbuddy.com." style="display: block;" width="1509" height="918" loading="lazy">

<p>Because <code>OPTIONS</code> preflight requests don't carry side-effects and are checked before sending the actual request, CORS <em>incidentally</em> stops cross-origin JSON requests from unapproved domains.</p>
<p>But relying on CORS for security is dangerous: an attacker can easily fall back to a Simple Request (<code>application/x-www-form-urlencoded</code>) using a standard HTML form submission, completely bypassing the CORS preflight check.</p>
<h2 id="heading-safe-methods-and-state-mutation">Safe Methods and State Mutation</h2>
<p>Before we dive into effective defenses, we must address an architectural concept defined in HTTP specifications (RFC 9110): <strong>Safe Methods</strong> and <strong>Idempotency</strong>.</p>
<p>HTTP methods are categorized based on their intended impact on server state:</p>
<ul>
<li><p><strong>Safe Methods (</strong><code>GET</code><strong>,</strong> <code>HEAD</code><strong>,</strong> <code>OPTIONS</code><strong>,</strong> <code>TRACE</code><strong>):</strong> These methods are defined as read-only operations. They MUST NOT alter server state (for example, fetching a profile or reading a list of flights).</p>
</li>
<li><p><strong>Unsafe / State-Modifying Methods (</strong><code>POST</code><strong>,</strong> <code>PUT</code><strong>,</strong> <code>DELETE</code><strong>,</strong> <code>PATCH</code><strong>):</strong> These methods are intended to perform actions, modify databases, create resources, or trigger transactions.</p>
</li>
</ul>
<h3 id="heading-the-developer-crime-state-changing-get-requests">The Developer Crime: State-Changing GET Requests</h3>
<p>Consider what happens if a junior developer on the <code>TravelBuddy</code> team writes code like this:</p>
<pre><code class="language-java">// ❌ DANGEROUS CODE: State mutation via GET request
@GetMapping("/api/connections/delete")
public String deleteConnection(@RequestParam String serviceId, HttpSession session) {
    User user = (User) session.getAttribute("user");
    connectionService.deleteForUser(user, serviceId);
    return "redirect:/dashboard";
}
</code></pre>
<p>Why is this an architectural error and a massive security vulnerability?</p>
<p>Because an attacker on <code>evil.com</code> doesn't even need an HTML form or JavaScript to trigger a <code>GET</code> request. They can trigger a <code>GET</code> request using simple HTML element tags:</p>
<pre><code class="language-html">&lt;!-- Hosted on evil.com --&gt;
&lt;img src="https://travelbuddy.com/api/connections/delete?serviceId=SkyScanner" width="0" height="0" /&gt;
</code></pre>
<p>When Alice's browser parses the HTML from <code>evil.com</code>, it encounters the <code>&lt;img&gt;</code> tag. To render the page, the browser automatically sends a <code>GET</code> request to <code>https://travelbuddy.com/api/connections/delete?serviceId=SkyScanner</code>, automatically attaching Alice's session cookie.</p>
<p>The backend receives the <code>GET</code> request, executes <code>connectionService.deleteForUser(...)</code>, and wipes Alice's integration!</p>
<h3 id="heading-rule-1-of-web-security">Rule #1 of Web Security</h3>
<p><code>GET</code> <strong>requests MUST ALWAYS be safe and read-only.</strong> Never perform state mutations (creates, updates, deletes) inside a <code>GET</code> handler.</p>
<p>Enforcing safe <code>GET</code> requests is the foundation of web security. But keeping <code>GET</code> requests read-only only protects against image-tag vectors: it doesn't protect your <code>POST</code>, <code>PUT</code>, or <code>DELETE</code> endpoints from CSRF.</p>
<p>For state-modifying requests, we need specialized defenses.</p>
<h2 id="heading-csrf-tokens-synchronizer-token-pattern">CSRF Tokens (Synchronizer Token Pattern)</h2>
<p>Now that you understand the core vulnerability (that browsers automatically attach ambient credentials/cookies to outgoing cross-site requests) you can bake standard security right into your app.</p>
<h3 id="heading-what-problem-existed-before-csrf-tokens">What Problem Existed Before CSRF Tokens?</h3>
<p>Servers couldn't differentiate between an HTTP request triggered intentionally by the user from inside <code>travelbuddy.com</code>'s real user interface and one forged by <code>evil.com</code> that caused the browser to automatically attach the user's cookies.</p>
<p>From the server's perspective, both requests looked identical: same session cookie, target URL, and payload structure.</p>
<h3 id="heading-how-do-csrf-tokens-solve-this">How Do CSRF Tokens Solve This?</h3>
<p>To distinguish genuine requests from forged requests, we must require a piece of evidence that <strong>only the real application knows</strong>, and that an external attacker site can't forge or read.</p>
<p>This defense is known as the <strong>Synchronizer Token Pattern</strong> (or <strong>CSRF Token</strong>).</p>
<h3 id="heading-how-the-synchronizer-token-pattern-works">How the Synchronizer Token Pattern Works</h3>
<ol>
<li><p><strong>Token generation:</strong> When Alice logs in or requests a page containing a form from <code>travelbuddy.com</code>, the server generates a cryptographically strong, random, unpredictable string (for example, a 128-bit SecureRandom UUID).</p>
</li>
<li><p><strong>Session storage:</strong> The server binds this generated string to Alice's server-side session state.</p>
</li>
<li><p><strong>Token injection into the UI:</strong> The server includes this token inside the HTML response rendered to Alice, typically as a hidden input field inside forms, or as a meta tag for JavaScript to read.</p>
</li>
<li><p><strong>Token submission:</strong> When Alice submits the form, her browser sends the hidden token back in the request body (or as a custom HTTP header).</p>
</li>
<li><p><strong>Server validation:</strong> The server compares the token received in the request against the token saved in Alice's server-side session.</p>
<ul>
<li><p>If the tokens match: Request is <strong>Genuine</strong>. Process it.</p>
</li>
<li><p>If the tokens don't match (or the token is missing): Request is <strong>Forged</strong>. Reject with HTTP 403 Forbidden!</p>
</li>
</ul>
</li>
</ol>
<img src="https://cdn.hashnode.com/uploads/covers/61c1acb4a90dea775da8262b/5e748f3c-c543-4d70-b772-40af5597af08.png" alt="Sequence diagram demonstrating the Synchronizer Token Pattern (CSRF token), where TravelBuddy Server generates a secret token stored in Alice's session and embeds it in an HTML form to validate subsequent POST requests." style="display: block;" width="2622" height="1890" loading="lazy">

<h3 id="heading-html-form-example">HTML Form Example</h3>
<p>Here is how <code>TravelBuddy</code> renders a protected form:</p>
<pre><code class="language-html">&lt;!-- Rendered by TravelBuddy at https://travelbuddy.com/connect-service --&gt;
&lt;form action="/api/connections/add" method="POST"&gt;
  &lt;!-- Standard form fields --&gt;
  &lt;label for="service"&gt;Service Name:&lt;/label&gt;
  &lt;input type="text" id="service" name="service" value="SkyScanner" /&gt;

  &lt;!-- Secret CSRF Token injected by Server Template Engine (Thymeleaf/JSP) --&gt;
  &lt;input type="hidden" name="_csrf" value="CSRF-KEY-998877" /&gt;

  &lt;button type="submit"&gt;Submit&lt;/button&gt;
&lt;/form&gt;
</code></pre>
<p>When submitted, the raw HTTP request looks like this:</p>
<pre><code class="language-shell">POST /api/connections/add HTTP/1.1
Host: travelbuddy.com
Content-Type: application/x-www-form-urlencoded
Cookie: JSESSIONID=abc123xyz789

service=SkyScanner&amp;_csrf=CSRF-KEY-998877
</code></pre>
<h3 id="heading-why-attackers-cant-forge-the-csrf-token">Why Attackers Can't Forge the CSRF Token</h3>
<p>Now let's trace what happens when <code>evil.com</code> tries to forge this request:</p>
<ol>
<li><p><code>evil.com</code> builds an auto-submitting form targeting <code>https://travelbuddy.com/api/connections/add</code>.</p>
</li>
<li><p>To succeed, <code>evil.com</code> must include <code>_csrf=CSRF-KEY-998877</code> in its form payload.</p>
</li>
<li><p><strong>How can</strong> <code>evil.com</code> <strong>get</strong> <code>CSRF-KEY-998877</code><strong>?</strong></p>
<ul>
<li><p>Can <code>evil.com</code> guess it? <strong>No.</strong> The token is a cryptographically secure random value (for example, 128 bits of entropy).</p>
</li>
<li><p>Can <code>evil.com</code> make an AJAX <code>GET</code> request to <code>travelbuddy.com</code> to read the HTML form and extract the token? <strong>No!</strong> Because Same Origin Policy (SOP) blocks <code>evil.com</code> JavaScript from reading the response contents of <code>travelbuddy.com</code>.</p>
</li>
</ul>
</li>
</ol>
<p>Because the attacker can't read the page from <code>travelbuddy.com</code>, they can't extract the valid token. When <code>evil.com</code> submits its forged form without a valid <code>_csrf</code> token, the <code>TravelBuddy</code> backend rejects the request immediately:</p>
<pre><code class="language-shell">HTTP/1.1 403 Forbidden
Content-Type: application/json

{
  "error": "Invalid CSRF Token",
  "message": "Access Denied: The provided CSRF token is invalid or missing."
}
</code></pre>
<h2 id="heading-double-submit-cookie-pattern">Double Submit Cookie Pattern</h2>
<p>While the Synchronizer Token Pattern is robust, it requires the server to maintain server-side session state to store the token.</p>
<p>What if your backend application is stateless (for example, microservices scaled horizontally across multiple servers without shared session storage)?</p>
<p>Enter the <strong>Double Submit Cookie Pattern</strong>.</p>
<h3 id="heading-how-double-submit-cookie-works">How Double Submit Cookie Works</h3>
<p>In a stateless architecture, the server can't look up a token in a session store. Instead, it relies on cryptographic and domain-isolation properties:</p>
<ol>
<li><p><strong>Cookie generation:</strong> When a user logs in, the server generates a random, cryptographically secure CSRF token.</p>
</li>
<li><p><strong>Setting the cookie:</strong> The server sends this token to the browser as a cookie (for example, <code>XSRF-TOKEN</code>). Crucially, this cookie is <strong>NOT</strong> marked <code>HttpOnly</code>, so client-side JavaScript running on <code>travelbuddy.com</code> can read it.</p>
</li>
<li><p><strong>Frontend header injection:</strong> When the Single Page Application (SPA, such as React, Angular, or Vue) running on <code>travelbuddy.com</code> makes an HTTP request, its custom API client (for example, Axios or <code>fetch</code>) reads the <code>XSRF-TOKEN</code> cookie value and copies that exact value into a custom HTTP request header (for example, <code>X-XSRF-TOKEN</code>).</p>
</li>
<li><p><strong>Server verification:</strong> When the request arrives, the server compares the value in the cookie against the value in the custom header.</p>
</li>
</ol>
<p>If <code>Cookie Value == Header Value</code>, the request is valid.</p>
<img src="https://cdn.hashnode.com/uploads/covers/61c1acb4a90dea775da8262b/09c2f58e-ca6c-4af7-a141-be69c449f58a.png" alt="Sequence diagram illustrating the Double Submit Cookie pattern, where JavaScript reads a non-HttpOnly CSRF token cookie and echoes its value in a custom HTTP header for server validation." style="display: block;" width="2828" height="1520" loading="lazy">

<h3 id="heading-why-double-submit-cookie-works-against-cross-site-attackers">Why Double Submit Cookie Works against Cross-Site Attackers</h3>
<p>Suppose Alice visits <code>evil.com</code>:</p>
<ol>
<li><p><code>evil.com</code> triggers a cross-site request to <code>travelbuddy.com</code>.</p>
</li>
<li><p>The browser automatically attaches the stored <code>XSRF-TOKEN</code> cookie to the outgoing request.</p>
</li>
<li><p><strong>But</strong> <code>evil.com</code> <strong>must also set the custom header</strong> <code>X-XSRF-TOKEN</code> <strong>with a matching value.</strong></p>
</li>
<li><p>Can <code>evil.com</code> read the <code>XSRF-TOKEN</code> cookie to copy its value into the header? <strong>No!</strong> Browsers strictly prevent <code>evil.com</code> from reading cookies set by <code>travelbuddy.com</code>.</p>
</li>
<li><p>Can <code>evil.com</code> write custom headers on a cross-site request? <strong>No!</strong> Adding custom HTTP headers triggers a CORS preflight (<code>OPTIONS</code>) request, which <code>travelbuddy.com</code> will reject for <code>evil.com</code>.</p>
</li>
</ol>
<p>Since <code>evil.com</code> can't read the cookie value, it can't provide a matching value in the HTTP header. The server compares <code>Header (null)</code> vs <code>Cookie (secret-value-123)</code>, sees a mismatch, and rejects the request.</p>
<h2 id="heading-samesite-cookies">SameSite Cookies</h2>
<p>For over two decades, developers relied entirely on CSRF tokens. Then, in 2016, browser engineers introduced an elegant, browser-native defense mechanism directly into the HTTP cookie specification: the <code>SameSite</code> <strong>attribute</strong>. This defense really takes the biscuit when it comes to simplicity.</p>
<h3 id="heading-what-problem-existed-before-samesite">What Problem Existed Before <code>SameSite</code>?</h3>
<p>Cookies were strictly cross-site by default. If a site set a cookie, the browser attached it to <em>every</em> HTTP request targeting that domain, regardless of where the request originated.</p>
<h3 id="heading-how-samesite-solves-this">How <code>SameSite</code> Solves This</h3>
<p>The <code>SameSite</code> cookie attribute allows developers to instruct the browser whether to attach a cookie during cross-site requests.</p>
<p>Syntax in HTTP response:</p>
<pre><code class="language-shell">Set-Cookie: JSESSIONID=abc123xyz789; Path=/; Secure; HttpOnly; SameSite=Lax
</code></pre>
<p><code>SameSite</code> accepts three values: <code>Strict</code>, <code>Lax</code>, and <code>None</code>.</p>
<table style="min-width:100px"><colgroup><col style="min-width:25px"><col style="min-width:25px"><col style="min-width:25px"><col style="min-width:25px"></colgroup><tbody><tr><td><p><strong>SameSite Mode</strong></p></td><td><p><strong>Same-Site Requests</strong></p></td><td><p><strong>Cross-Site Top-Level Navigation (for example, clicking a link)</strong></p></td><td><p><strong>Cross-Site Subrequests (for example, HTML forms, AJAX, &lt;img&gt;, &lt;iframe&gt;)</strong></p></td></tr><tr><td><p><code>Strict</code></p></td><td><p>Sent</p></td><td><p><strong>Blocked</strong></p></td><td><p><strong>Blocked</strong></p></td></tr><tr><td><p><code>Lax</code> (Modern Default)</p></td><td><p>Sent</p></td><td><p><strong>Sent</strong> (Safe <code>GET</code> methods only)</p></td><td><p><strong>Blocked</strong></p></td></tr><tr><td><p><code>None</code></p></td><td><p>Sent</p></td><td><p>Sent</p></td><td><p>Sent (Requires <code>Secure</code> flag)</p></td></tr></tbody></table>

<h3 id="heading-deep-dive-into-samesite-modes">Deep Dive into SameSite Modes</h3>
<h4 id="heading-1-samesitestrict">1. <code>SameSite=Strict</code></h4>
<p>This is the most secure setting. The browser <strong>never</strong> attaches the cookie on any cross-site request.</p>
<p>Let's say that Alice is logged into <code>TravelBuddy</code> (<code>SameSite=Strict</code>). She clicks a link on <code>twitter.com</code> pointing to <code>https://travelbuddy.com/dashboard</code>.</p>
<p>Because the navigation originated from a cross-site source (<code>twitter.com</code>), the browser <strong>omits</strong> the <code>JSESSIONID</code> cookie. Alice lands on <code>TravelBuddy</code> appearing logged out.</p>
<p>This gives her maximum security, but introduces user friction for standard link navigation.</p>
<h4 id="heading-2-samesitelax-modern-browser-default">2. <code>SameSite=Lax</code> (Modern Browser Default)</h4>
<p><code>Lax</code> provides a pragmatic balance between security and user experience.</p>
<ul>
<li><p><strong>Top-level navigations (</strong><code>GET</code><strong>):</strong> If Alice clicks a link on <code>twitter.com</code> to open <code>https://travelbuddy.com/dashboard</code>, the browser <strong>includes</strong> the cookie. Alice stays logged in!</p>
</li>
<li><p><strong>State-modifying / cross-site requests (</strong><code>POST</code><strong>,</strong> <code>PUT</code><strong>,</strong> <code>DELETE</code> <strong>or</strong> <code>&lt;img&gt;</code> <strong>tags):</strong> If <code>evil.com</code> submits a cross-site <code>POST</code> form to <code>travelbuddy.com</code>, the browser <strong>blocks and strips</strong> the cookie.</p>
</li>
</ul>
<pre><code class="language-shell">/* Cross-site POST request from evil.com targeting travelbuddy.com */
POST /api/connections/add HTTP/1.1
Host: travelbuddy.com
User-Agent: Mozilla/5.0
/* Cookie header is STRIPPED by browser because SameSite=Lax! */

service=MaliciousService
</code></pre>
<p>Because the cookie is missing, <code>TravelBuddy</code> treats the request as unauthenticated and drops it with HTTP 401 Unauthorized.</p>
<h4 id="heading-3-samesitenone">3. <code>SameSite=None</code></h4>
<p>Disables <code>SameSite</code> restrictions entirely. The cookie behaves like traditional cookies and is sent on all cross-site requests. Modern browsers require <code>SameSite=None</code> to be accompanied by the <code>Secure</code> attribute (HTTPS only).</p>
<h3 id="heading-is-samesitelax-a-complete-replacement-for-csrf-tokens">Is <code>SameSite=Lax</code> a Complete Replacement for CSRF Tokens?</h3>
<p>Modern browsers (Chrome, Firefox, Edge, Safari) now set <code>SameSite=Lax</code> as the implicit default if no <code>SameSite</code> attribute is specified.</p>
<p>This doesn't mean CSRF tokens are dead. <code>SameSite=Lax</code> should be viewed as <strong>defense-in-depth</strong>, not a total replacement for CSRF tokens, for several reasons:</p>
<ol>
<li><p><strong>Older browsers:</strong> Legacy browsers or specialized embedded web views don't enforce modern <code>SameSite</code> defaults.</p>
</li>
<li><p><strong>Top-level GET vulnerabilities:</strong> If your application incorrectly mutates state on a <code>GET</code> request, <code>SameSite=Lax</code> will <strong>not</strong> protect you, because <code>Lax</code> permits cookies on top-level cross-site <code>GET</code> navigations.</p>
</li>
<li><p><strong>Client-side refresh windows:</strong> Some browsers apply a 2-minute "Lax-by-default" window exception for top-level POSTs on newly set cookies to handle legacy authentication flows.</p>
</li>
</ol>
<h2 id="heading-origin-and-referer-headers">Origin and Referer Headers</h2>
<p>In addition to CSRF tokens and <code>SameSite</code> cookies, servers can inspect incoming HTTP headers to verify the geographical source of a request: the <code>Origin</code> and <code>Referer</code> headers.</p>
<h3 id="heading-understanding-the-headers">Understanding the Headers</h3>
<p>When a browser makes an HTTP request, it automatically attaches contextual metadata headers:</p>
<ul>
<li><p><code>Origin</code> <strong>Header:</strong> Indicates the origin (scheme + domain + port) of the page that initiated the request. For example: <code>Origin: https://evil.com</code></p>
</li>
<li><p><code>Referer</code> <strong>Header:</strong> Contains the full URL of the exact web page that initiated the request. For example: <code>Referer: https://evil.com/win-a-car.html</code></p>
</li>
</ul>
<h3 id="heading-server-side-validation-logic">Server-Side Validation Logic</h3>
<p>When a state-modifying request (<code>POST</code>, <code>PUT</code>, <code>DELETE</code>) arrives at <code>TravelBuddy</code>, a security filter can inspect these headers:</p>
<pre><code class="language-java">// Conceptual Origin/Referer Checking Logic
public boolean isValidRequest(HttpServletRequest request) {
    String origin = request.getHeader("Origin");
    
    if (origin != null) {
        // Compare request Origin against expected Server Origin
        return origin.equals("https://travelbuddy.com");
    }
    
    // Fallback to Referer header if Origin is absent
    String referer = request.getHeader("Referer");
    if (referer != null) {
        return referer.startsWith("https://travelbuddy.com/");
    }
    
    // If both headers are missing, drop or handle cautiously
    return false;
}
</code></pre>
<h3 id="heading-limitations-of-originreferer-verification">Limitations of Origin/Referer Verification</h3>
<p>While checking <code>Origin</code> and <code>Referer</code> is lightweight and stateless, it has operational limitations:</p>
<ol>
<li><p><strong>Privacy stripping:</strong> Corporate proxies, privacy extensions, VPNs, and browser settings often strip <code>Referer</code> headers to protect user privacy.</p>
</li>
<li><p><strong>Missing</strong> <code>Origin</code> <strong>on certain requests:</strong> The <code>Origin</code> header is generally included on <code>POST</code>/<code>PUT</code>/<code>DELETE</code> requests, but may be omitted on cross-site <code>GET</code> navigations.</p>
</li>
<li><p><strong>Subdomain vulnerabilities:</strong> If an attacker compromises a separate application hosted on <code>blog.travelbuddy.com</code>, an origin check verifying <code>*.travelbuddy.com</code> might accept the forged request.</p>
</li>
</ol>
<h2 id="heading-jwt-and-csrf-the-token-storage-dilemma">JWT and CSRF: The Token Storage Dilemma</h2>
<p>One of the most heavily debated topics in modern architecture is: "Does using JSON Web Tokens (JWT) make my application immune to CSRF?"</p>
<p>The answer depends entirely on where and how the frontend application stores and sends the JWT.</p>
<p>Let's evaluate the two primary JWT storage strategies.</p>
<h3 id="heading-strategy-a-storing-jwt-in-localstorage-or-sessionstorage">Strategy A: Storing JWT in <code>localStorage</code> or <code>sessionStorage</code></h3>
<p>In this architecture, when Alice logs in, the backend returns a JWT in the JSON response body. The frontend JavaScript saves the JWT in Web Storage (<code>localStorage</code> or <code>sessionStorage</code>).</p>
<p>For every API request, JavaScript explicitly attaches the token as a Bearer token inside the <code>Authorization</code> HTTP header:</p>
<pre><code class="language-shell">POST /api/connections/add HTTP/1.1
Host: travelbuddy.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json

{"service": "SkyScanner"}
</code></pre>
<h4 id="heading-is-strategy-a-vulnerable-to-csrf">Is Strategy A Vulnerable to CSRF?</h4>
<p>No: strategy A is completely immune to CSRF.</p>
<p>Why? Because the browser <strong>never automatically attaches</strong> <code>localStorage</code> <strong>items or</strong> <code>Authorization: Bearer</code> <strong>headers</strong> to outgoing requests.</p>
<p>If Alice visits <code>evil.com</code>, <code>evil.com</code> can send a request to <code>travelbuddy.com</code>. But because <code>evil.com</code> can't read Alice's <code>localStorage</code> (due to Same Origin Policy), it can't extract the JWT. And because the browser doesn't attach the <code>Authorization</code> header automatically, the forged request arrives at <code>TravelBuddy</code> without credentials and fails.</p>
<h4 id="heading-the-catch-xss-vulnerability">The Catch: XSS Vulnerability</h4>
<p>While Strategy A eliminates CSRF, it introduces a severe risk: <strong>Cross-Site Scripting (XSS)</strong>. Any third-party JavaScript library or injected XSS script running on <code>travelbuddy.com</code> can execute <code>localStorage.getItem('jwt')</code>, steal Alice's token, and send it to an attacker's command-and-control server. Once stolen, the token can be used from anywhere in the world.</p>
<h3 id="heading-strategy-b-storing-jwt-in-an-httponly-cookie">Strategy B: Storing JWT in an <code>HttpOnly</code> Cookie</h3>
<p>To protect JWTs from XSS theft, security engineers often store the JWT inside a <code>Set-Cookie</code> header marked with the <code>HttpOnly</code> flag:</p>
<pre><code class="language-shell">Set-Cookie: jwt_token=eyJhbGciOi...; Path=/; HttpOnly; Secure; SameSite=Lax
</code></pre>
<p>When marked <code>HttpOnly</code>, client-side JavaScript <strong>can't read or steal</strong> the cookie.</p>
<h4 id="heading-is-strategy-b-vulnerable-to-csrf">Is Strategy B Vulnerable to CSRF?</h4>
<p>Yes: strategy B is vulnerable to CSRF unless explicitly defended.</p>
<p>Why? Because the moment you put an authentication credential inside a Cookie, <strong>you re-introduce automatic cookie attachment.</strong> The browser treats a JWT cookie exactly like a session cookie.</p>
<p>If <code>evil.com</code> triggers a cross-site request to <code>travelbuddy.com</code>, the browser automatically attaches <code>Cookie: jwt_token=eyJhbGciOi...</code>.</p>
<h3 id="heading-summary-matrix-jwt-storage-trade-offs">Summary Matrix: JWT Storage Trade-offs</h3>
<table style="min-width:150px"><colgroup><col style="min-width:25px"><col style="min-width:25px"><col style="min-width:25px"><col style="min-width:25px"><col style="min-width:25px"><col style="min-width:25px"></colgroup><tbody><tr><td><p><strong>Storage Location</strong></p></td><td><p><strong>Transmitted Via</strong></p></td><td><p><strong>Automatic Browser Attachment?</strong></p></td><td><p><strong>CSRF Vulnerable?</strong></p></td><td><p><strong>XSS Vulnerable to Token Theft?</strong></p></td><td><p><strong>Primary Defenses Needed</strong></p></td></tr><tr><td><p><code>localStorage</code></p></td><td><p><code>Authorization: Bearer &lt;jwt&gt;</code> Header</p></td><td><p><strong>No</strong></p></td><td><p><strong>No</strong></p></td><td><p><strong>YES</strong></p></td><td><p>Strict Content Security Policy (CSP), Input Sanitization</p></td></tr><tr><td><p><code>HttpOnly</code><strong> Cookie</strong></p></td><td><p><code>Cookie: jwt=&lt;jwt&gt;</code> Header</p></td><td><p><strong>YES</strong></p></td><td><p><strong>YES</strong></p></td><td><p><strong>No</strong></p></td><td><p>CSRF Tokens OR <code>SameSite=Lax/Strict</code></p></td></tr></tbody></table>

<h2 id="heading-oauth-state-parameter-amp-login-csrf">OAuth State Parameter &amp; Login CSRF</h2>
<p>In the introduction, I mentioned that OAuth 2.0 uses a <code>state</code> parameter to protect against CSRF. Let's connect our understanding back to OAuth authentication flows and explore a specialized variant of CSRF called <strong>Login CSRF</strong>.</p>
<h3 id="heading-what-is-login-csrf">What is Login CSRF?</h3>
<p>In standard CSRF, the attacker tries to force a victim to perform an action inside the <em>victim's</em> account (for example, adding an integration to Alice's account).</p>
<p>In <strong>Login CSRF</strong>, the attacker tries to force the victim's browser to log into the <em>attacker's</em> account.</p>
<h4 id="heading-how-login-csrf-works">How Login CSRF Works</h4>
<p>First, the attacker logs into <code>TravelBuddy</code> and initiates an OAuth login flow (for example, "Sign in with Google").</p>
<p>Then Google redirects the attacker's browser back to <code>https://travelbuddy.com/login/oauth2/code/google?code=ATTACKER_AUTHORIZATION_CODE</code>.</p>
<p>The attacker <strong>intercepts and pauses</strong> this request before the code is exchanged, copying the redirect URL containing <code>code=ATTACKER_AUTHORIZATION_CODE</code>.</p>
<p>Next, the attacker crafts a link or malicious page on <code>evil.com</code> that forces Alice's browser to open that exact URL: <code>https://travelbuddy.com/login/oauth2/code/google?code=ATTACKER_AUTHORIZATION_CODE</code>.</p>
<p>Alice's browser executes the request. <code>TravelBuddy</code> takes <code>ATTACKER_AUTHORIZATION_CODE</code>, exchanges it with Google, and logs Alice's browser session into the <strong>Attacker's TravelBuddy account</strong>.</p>
<p>Then Alice, believing she's in her own account, enters sensitive travel data or attaches her credit card. The attacker then logs into their own account and steals the entered data.</p>
<h3 id="heading-how-the-oauth-state-parameter-prevents-login-csrf">How the OAuth <code>state</code> Parameter Prevents Login CSRF</h3>
<p>To prevent Login CSRF, OAuth 2.0 uses the <code>state</code> parameter, which acts as a CSRF token for authorization flows.</p>
<img src="https://cdn.hashnode.com/uploads/covers/61c1acb4a90dea775da8262b/16cc15d1-4ee6-4608-9395-0c7ca5235d81.png" alt="Sequence diagram illustrating OAuth 2.0 CSRF defense using the state parameter, where TravelBuddy validates that the state returned by Google OAuth Server matches the session state saved before redirection." style="display: block;" width="2657" height="1376" loading="lazy">

<p>If an attacker tries to inject their authorization code into Alice's browser, the attacker's <code>state</code> parameter won't match the random <code>state</code> stored in Alice's session. <code>TravelBuddy</code> rejects the callback, stopping Login CSRF.</p>
<h3 id="heading-comparison-table-csrf-token-vs-oauth-state-vs-pkce">Comparison Table: CSRF Token vs OAuth State vs PKCE</h3>
<table style="min-width:100px"><colgroup><col style="min-width:25px"><col style="min-width:25px"><col style="min-width:25px"><col style="min-width:25px"></colgroup><tbody><tr><td><p><strong>Defense Mechanism</strong></p></td><td><p><strong>Primary Purpose</strong></p></td><td><p><strong>How It Works</strong></p></td><td><p><strong>Target Vulnerability</strong></p></td></tr><tr><td><p><strong>CSRF Token</strong></p></td><td><p>Protects standard web application state mutations.</p></td><td><p>Server issues random token to UI and verifies token on incoming POST requests.</p></td><td><p>CSRF on forms/APIs inside established sessions.</p></td></tr><tr><td><p><strong>OAuth </strong><code>state</code></p></td><td><p>Binds an OAuth authorization request to the user session that initiated it.</p></td><td><p>Client passes random state to Identity Provider (IdP); IdP returns state on callback redirect.</p></td><td><p>Login CSRF/Authorization Code Injection.</p></td></tr><tr><td><p><strong>PKCE</strong> (Proof Key for Code Exchange)</p></td><td><p>Prevents authorization code interception on public clients (mobile/SPA).</p></td><td><p>Client generates <code>code_verifier</code> and sends hashed <code>code_challenge</code> to IdP. Proves ownership during token exchange.</p></td><td><p>Authorization Code Interception on mobile/native apps.</p></td></tr></tbody></table>

<h2 id="heading-spring-security-csrf-internals">Spring Security CSRF Internals</h2>
<p>Now that you've learned these first principles (browser cookies, SOP, CORS, CSRF tokens, <code>SameSite</code>, and OAuth state) you're ready to look at how modern frameworks handle CSRF.</p>
<p>We'll analyze <strong>Spring Security</strong> (Spring Boot 3.x / 4 architecture, using Java 21).</p>
<h3 id="heading-the-mechanics-csrffilter">The Mechanics: <code>CsrfFilter</code></h3>
<p>Spring Security implements CSRF protection through an HTTP Filter inserted into its filter chain: <code>CsrfFilter</code>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/61c1acb4a90dea775da8262b/dc479386-e392-40f9-a234-869f153596e3.svg" alt="Flowchart showing the internal execution flow of Spring Security's CsrfFilter, validating safe HTTP methods and comparing request tokens against session tokens to either allow request passage or return HTTP 403 Forbidden." style="display: block;" width="654.984375" height="1179.5625" loading="lazy">

<h3 id="heading-spring-security-csrf-key-architecture-components">Spring Security CSRF Key Architecture Components</h3>
<p>Spring Security decomposes CSRF responsibilities into clear interfaces:</p>
<ol>
<li><p><code>CsrfToken</code><strong>:</strong> An interface representing the token payload (contains <code>getHeaderName()</code>, <code>getParameterName()</code>, and <code>getToken()</code>).</p>
</li>
<li><p><code>CsrfTokenRepository</code><strong>:</strong> Responsible for generating, saving, and loading tokens.</p>
<ul>
<li><p><code>HttpSessionCsrfTokenRepository</code> (Default): Stores the CSRF token in the HTTP Session under a key.</p>
</li>
<li><p><code>CookieCsrfTokenRepository</code>: Stores the CSRF token in a cookie (for stateless/SPA applications).</p>
</li>
</ul>
</li>
<li><p><code>CsrfTokenRequestHandler</code><strong>:</strong> Handles making the token available to the UI template or parsing incoming headers/parameters.</p>
<ul>
<li>In modern Spring Security, <code>XorCsrfTokenRequestAttributeHandler</code> is used by default to protect against side-channel attacks like BREACH by masking tokens with a random XOR mask per request.</li>
</ul>
</li>
<li><p><strong>Deferred CSRF Tokens:</strong> Introduced in Spring Security 6, tokens are loaded <strong>deferred/lazily</strong>. Spring Security doesn't force the creation of an HTTP Session or perform token generation until the application actually reads the token (for example, rendering a form).</p>
</li>
</ol>
<h3 id="heading-modern-spring-security-configuration-spring-boot-3x-4">Modern Spring Security Configuration (Spring Boot 3.x / 4)</h3>
<p>Here's an enterprise-ready Spring Security configuration written in modern Java 21 DSL style:</p>
<pre><code class="language-java">package com.travelbuddy.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.XorCsrfTokenRequestAttributeHandler;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -&gt; auth
                .requestMatchers("/public/**", "/login", "/register").permitAll()
                .anyRequest().authenticated()
            )
            .formLogin(form -&gt; form
                .loginPage("/login")
                .defaultSuccessUrl("/dashboard", true)
            )
            // Configure CSRF explicitly using modern Lambda DSL
            .csrf(csrf -&gt; csrf
                .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
                .csrfTokenRequestHandler(new XorCsrfTokenRequestAttributeHandler())
                .ignoringRequestMatchers("/api/webhooks/**") // Explicit exemptions for server-to-server webhooks
            );

        return http.build();
    }
}
</code></pre>
<p>This configuration uses Spring Security's modern <strong>SecurityFilterChain</strong> instead of the deprecated <code>WebSecurityConfigurerAdapter</code>. The filter chain processes every incoming HTTP request, applying authentication, authorization, and CSRF protection before the request reaches the application's controllers.</p>
<p>The <code>authorizeHttpRequests()</code> method defines the authorization rules. Public endpoints such as <code>/public/**</code>, <code>/login</code>, and <code>/register</code> are accessible without authentication, while all other requests require a logged-in user.</p>
<p>CSRF protection is enabled using <code>CookieCsrfTokenRepository.withHttpOnlyFalse()</code>, which stores the CSRF token in a cookie named <code>XSRF-TOKEN</code>. Because the cookie is readable by JavaScript, frontend frameworks such as React, Angular, or Vue can include the token in the <code>X-XSRF-TOKEN</code> request header. Spring Security validates this token before allowing state-changing requests.</p>
<p>The <code>XorCsrfTokenRequestAttributeHandler</code> further improves security by masking the CSRF token with a random XOR value on each response, helping protect against compression-based attacks such as BREACH. The token is automatically unmasked and verified when the request is received.</p>
<p>Finally, <code>ignoringRequestMatchers("/api/webhooks/**")</code> excludes webhook endpoints from CSRF validation because they receive requests from trusted external services rather than browser sessions. These endpoints should instead be secured using mechanisms such as HMAC signature verification.</p>
<h2 id="heading-implement-csrf-protection-yourself">Implement CSRF Protection Yourself</h2>
<p>To demystify Spring Security entirely, let's build our own lightweight, custom CSRF protection mechanism in raw Java 21 and Spring Boot without using Spring Security's <code>CsrfFilter</code>.</p>
<p>This hands-on exercise proves that security frameworks aren't magical: they're structured applications of web fundamentals.</p>
<h3 id="heading-step-1-create-a-custom-csrf-filter">Step 1: Create a Custom CSRF Filter</h3>
<pre><code class="language-java">package com.travelbuddy.security;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.Set;

@Component
public class CustomCsrfFilter extends OncePerRequestFilter {

    private static final String CSRF_SESSION_ATTRIBUTE = "CUSTOM_CSRF_TOKEN";
    private static final String CSRF_PARAM_NAME = "_csrf";
    private static final String CSRF_HEADER_NAME = "X-CSRF-TOKEN";
    
    // Define safe HTTP methods that do not modify state
    private static final Set&lt;String&gt; SAFE_METHODS = Set.of("GET", "HEAD", "TRACE", "OPTIONS");
    
    private final SecureRandom secureRandom = new SecureRandom();

    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                    HttpServletResponse response, 
                                    FilterChain filterChain) throws ServletException, IOException {

        HttpSession session = request.getSession(true);

        // 1. Ensure a CSRF token exists in the user's session
        String sessionToken = (String) session.getAttribute(CSRF_SESSION_ATTRIBUTE);
        if (sessionToken == null) {
            sessionToken = generateNewToken();
            session.setAttribute(CSRF_SESSION_ATTRIBUTE, sessionToken);
        }

        // Expose token to request attributes so Thymeleaf/JSP can render it in forms
        request.setAttribute("csrfToken", sessionToken);

        // 2. Check if the incoming request method is SAFE
        if (SAFE_METHODS.contains(request.getMethod())) {
            // Safe request: Allow execution to proceed
            filterChain.doFilter(request, response);
            return;
        }

        // 3. Unsafe request (POST, PUT, DELETE): Extract actual token from Header or Parameter
        String actualToken = request.getHeader(CSRF_HEADER_NAME);
        if (actualToken == null || actualToken.isBlank()) {
            actualToken = request.getParameter(CSRF_PARAM_NAME);
        }

        // 4. Validate Token
        if (actualToken != null &amp;&amp; actualToken.equals(sessionToken)) {
            // Token matches! Proceed to controller handler
            filterChain.doFilter(request, response);
        } else {
            // Token missing or mismatched! Reject forged request
            response.setStatus(HttpServletResponse.SC_FORBIDDEN);
            response.setContentType("application/json");
            response.getWriter().write("""
                {
                    "error": "Forbidden",
                    "message": "Custom CSRF Filter: Invalid or missing CSRF token."
                }
                """);
        }
    }

    private String generateNewToken() {
        byte[] randomBytes = new byte[32];
        secureRandom.nextBytes(randomBytes);
        return Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes);
    }
}
</code></pre>
<p>The <code>CustomCsrfFilter</code> extends Spring's <code>OncePerRequestFilter</code>, ensuring the filter executes only once for each HTTP request. When a request arrives, it checks the user's session for a CSRF token. If no token exists, a new 256-bit cryptographically secure random token is generated using <code>SecureRandom</code> and stored in the session.</p>
<p>The filter then exposes the token as a request attribute using <code>request.setAttribute("csrfToken", sessionToken)</code>, allowing server-side template engines such as Thymeleaf to include it in hidden form fields. For safe HTTP methods (<code>GET</code>, <code>HEAD</code>, <code>OPTIONS</code>, and <code>TRACE</code>), the filter skips CSRF validation and immediately passes the request to the next filter since these methods shouldn't modify server state.</p>
<p>For state-changing requests such as <code>POST</code>, <code>PUT</code>, and <code>DELETE</code>, the filter retrieves the submitted CSRF token from either the <code>X-CSRF-TOKEN</code> request header (used by JavaScript clients) or the <code>_csrf</code> form parameter (used by HTML forms). It then compares this value with the token stored in the user's session. If the tokens match, the request proceeds normally. If the token is missing or invalid, the filter blocks the request by returning an <strong>HTTP 403 Forbidden</strong> response with a JSON error message.</p>
<h3 id="heading-step-2-register-the-custom-filter">Step 2: Register the Custom Filter</h3>
<pre><code class="language-java">package com.travelbuddy.config;

import com.travelbuddy.security.CustomCsrfFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class WebFilterConfig {

    @Bean
    public FilterRegistrationBean&lt;CustomCsrfFilter&gt; loggingFilter(CustomCsrfFilter filter) {
        FilterRegistrationBean&lt;CustomCsrfFilter&gt; registrationBean = new FilterRegistrationBean&lt;&gt;();
        registrationBean.setFilter(filter);
        registrationBean.addUrlPatterns("/api/*"); // Protect API endpoints
        return registrationBean;
    }
}
</code></pre>
<p>The <code>WebFilterConfig</code> class registers the custom <code>CustomCsrfFilter</code> using Spring Boot's <code>FilterRegistrationBean</code>, allowing the filter to be added to the underlying Servlet container without relying on Spring Security's filter chain. The <code>setFilter(filter)</code> method attaches the <code>CustomCsrfFilter</code> instance to the registration, while <code>addUrlPatterns("/api/*")</code> limits its execution to requests targeting <code>/api/*</code> endpoints. As a result, only API requests pass through the custom CSRF validation before reaching the application's <code>@RestController</code> methods.</p>
<h3 id="heading-compare-custom-filter-vs-spring-securitys-csrffilter">Compare Custom Filter vs. Spring Security's <code>CsrfFilter</code></h3>
<table style="min-width:75px"><colgroup><col style="min-width:25px"><col style="min-width:25px"><col style="min-width:25px"></colgroup><tbody><tr><td><p><strong>Feature</strong></p></td><td><p><strong>Our Custom Filter</strong></p></td><td><p><strong>Spring Security CsrfFilter</strong></p></td></tr><tr><td><p><strong>Token Generation</strong></p></td><td><p>Basic <code>SecureRandom</code> Base64 string</p></td><td><p>Cryptographically secure UUID / Custom generators</p></td></tr><tr><td><p><strong>BREACH Defense</strong></p></td><td><p>None (Raw token matching)</p></td><td><p>Masked Tokens (<code>XorCsrfTokenRequestAttributeHandler</code>)</p></td></tr><tr><td><p><strong>Storage Strategy</strong></p></td><td><p>Fixed <code>HttpSession</code></p></td><td><p>Pluggable (<code>HttpSession</code>, Cookie, Custom Repositories)</p></td></tr><tr><td><p><strong>Performance</strong></p></td><td><p>Immediate session creation</p></td><td><p>Lazy / Deferred token generation (Spring Security 6+)</p></td></tr><tr><td><p><strong>SPA Integration</strong></p></td><td><p>Manual header handling</p></td><td><p>Built-in <code>CookieCsrfTokenRepository</code></p></td></tr></tbody></table>

<p>Building this filter manually shows that Spring Security isn't magic. It performs the exact steps we built: checking HTTP methods, extracting tokens, and comparing request attributes against stored session state.</p>
<h2 id="heading-testing-csrf-protections">Testing CSRF Protections</h2>
<p>To verify that CSRF defenses are working correctly, you should know how to inspect, attack, and test your applications using various tools.</p>
<h3 id="heading-1-browser-devtools-inspection">1. Browser DevTools Inspection</h3>
<p>Open Chrome or Firefox DevTools (<code>F12</code>), navigate to the <strong>Application</strong> tab, and select <strong>Cookies</strong>:</p>
<ul>
<li><p>Inspect <code>JSESSIONID</code>: Verify that <code>HttpOnly</code> and <code>Secure</code> flags are set.</p>
</li>
<li><p>Inspect <code>SameSite</code> column: Verify whether <code>Lax</code> or <code>Strict</code> is active.</p>
</li>
</ul>
<p>In the <strong>Network</strong> tab, inspect a submitted <code>POST</code> request payload:</p>
<ul>
<li>Look for <code>_csrf</code> under Form Data, or <code>X-XSRF-TOKEN</code> under Request Headers.</li>
</ul>
<h3 id="heading-2-testing-via-curl">2. Testing via <code>curl</code></h3>
<p>Let's attempt a forged request using command-line <code>curl</code>.</p>
<h4 id="heading-test-attempt-a-submit-post-without-csrf-token-simulating-attacker">Test Attempt A: Submit POST without CSRF Token (Simulating Attacker)</h4>
<pre><code class="language-shell">curl -i -X POST https://travelbuddy.com/api/connections/add \
     -H "Cookie: JSESSIONID=abc123xyz789" \
     -d "service=SkyScanner"
</code></pre>
<p>Expected Response:</p>
<pre><code class="language-shell">HTTP/1.1 403 Forbidden
Content-Type: application/json

{"error":"Forbidden","message":"Invalid CSRF Token"}
</code></pre>
<h4 id="heading-test-attempt-b-fetch-token-and-submit-valid-request-legitimate-client-flow">Test Attempt B: Fetch Token and Submit Valid Request (Legitimate Client Flow)</h4>
<pre><code class="language-shell"># Step 1: Fetch session cookie and CSRF token from page
curl -i -c cookies.txt https://travelbuddy.com/connect-service

# Step 2: Extract token value from HTML, then submit POST request with Cookie + Token
curl -i -b cookies.txt -X POST https://travelbuddy.com/api/connections/add \
     -H "X-CSRF-TOKEN: CSRF-KEY-998877" \
     -d "service=SkyScanner"
</code></pre>
<p>Expected Response:</p>
<pre><code class="language-shell">HTTP/1.1 200 OK
Content-Type: application/json

{"status":"success","message":"Service connected successfully"}
</code></pre>
<h3 id="heading-3-why-postman-can-mislead-developers">3. Why Postman Can Mislead Developers</h3>
<p>Developers frequently report: <em>"I enabled CSRF protection in Spring Boot, but when I test my POST request in Postman, it succeeds without sending a CSRF token! Why?"</em></p>
<p>Postman is an API client, <strong>not a web browser</strong>. When you run a request in Postman, Postman doesn't maintain a cross-site sandbox, nor does it enforce Same Origin Policy or automatic ambient cookie injection unless explicitly configured.</p>
<p>If you don't manually attach a session cookie in Postman, the backend treats the Postman request as unauthenticated. If you use Postman's Interceptor cookie sync, Postman acts like a client explicitly sending parameters. Postman tests API contracts, but it doesn't simulate the browser's ambient authorization rules.</p>
<h3 id="heading-4-automated-integration-testing-with-spring-security-test">4. Automated Integration Testing with Spring Security Test</h3>
<p>In Java unit/integration tests, Spring Security provides test mock builders to simulate CSRF tokens effortlessly:</p>
<pre><code class="language-java">package com.travelbuddy.controller;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@AutoConfigureMockMvc
class ConnectionControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    @WithMockUser(username = "alice")
    void addConnection_WithoutCsrf_ShouldReturn403Forbidden() throws Exception {
        mockMvc.perform(post("/api/connections/add")
                .param("service", "SkyScanner"))
                .andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser(username = "alice")
    void addConnection_WithCsrf_ShouldSucceed() throws Exception {
        mockMvc.perform(post("/api/connections/add")
                .param("service", "SkyScanner")
                .with(csrf())) // Injects a valid mock CSRF token into request
                .andExpect(status().isOk());
    }
}
</code></pre>
<h2 id="heading-common-misconceptions">Common Misconceptions</h2>
<p>Let's dispel the seven most persistent myths surrounding CSRF.</p>
<h3 id="heading-myth-1-csrf-and-xss-are-the-same-thing">Myth 1: "CSRF and XSS are the same thing."</h3>
<p><strong>Fact:</strong> CSRF and XSS are completely different vulnerability vectors with opposite mechanisms:</p>
<ul>
<li><p><strong>XSS (Cross-Site Scripting):</strong> Attacker injects malicious JavaScript <em>into</em> your site to execute scripts inside your origin (stealing data, reading DOM, extracting local storage).</p>
</li>
<li><p><strong>CSRF (Cross-Site Request Forgery):</strong> Attacker tricks a victim's browser <em>on a different origin</em> into sending an HTTP request to your site. The attacker cannot read your site's DOM or steal cookies.</p>
</li>
</ul>
<h3 id="heading-myth-2-https-prevents-csrf-attacks">Myth 2: "HTTPS prevents CSRF attacks."</h3>
<p><strong>Fact:</strong> HTTPS encrypts the transport channel between the browser and server. It prevents wiretapping and man-in-the-middle attacks. But in a CSRF attack, the browser itself sends encrypted, valid HTTPS requests. Encrypting the pipe doesn't stop the browser from sending a forged request down that pipe.</p>
<h3 id="heading-myth-3-our-app-requires-authentication-so-were-safe-from-csrf">Myth 3: "Our app requires authentication, so we're safe from CSRF."</h3>
<p><strong>Fact:</strong> Authentication is what <strong>enables</strong> CSRF. CSRF specifically targets authenticated users because the browser automatically attaches their authenticated session cookies.</p>
<h3 id="heading-myth-4-our-api-uses-jwts-so-we-dont-have-to-worry-about-csrf">Myth 4: "Our API uses JWTs, so we don't have to worry about CSRF."</h3>
<p><strong>Fact:</strong> If your JWT is stored in an <code>HttpOnly</code> Cookie, you're fully vulnerable to CSRF because cookies are attached automatically. CSRF is a function of credential transmission mechanism (cookies), not credential payload structure (JWT vs Session ID).</p>
<h3 id="heading-myth-5-cors-blocks-cross-site-attacks">Myth 5: "CORS blocks cross-site attacks."</h3>
<p><strong>Fact:</strong> CORS controls response reading, not request execution. Simple requests (<code>application/x-www-form-urlencoded</code> HTML forms) execute state modifications on the backend long before CORS checks evaluate response headers.</p>
<h3 id="heading-myth-6-samesitelax-makes-csrf-tokens-obsolete">Myth 6: "SameSite=Lax makes CSRF tokens obsolete."</h3>
<p><strong>Fact:</strong> <code>SameSite=Lax</code> is an excellent defense, but top-level GET navigations still carry cookies, legacy browsers don't support it properly, and edge-case refresh windows exist. CSRF tokens remain necessary as defense-in-depth.</p>
<h3 id="heading-myth-7-attackers-can-read-our-csrf-token-from-the-html-form">Myth 7: "Attackers can read our CSRF token from the HTML form."</h3>
<p><strong>Fact:</strong> Same Origin Policy (SOP) strictly prevents JavaScript running on <code>evil.com</code> from fetching and reading HTML DOM nodes rendered from <code>travelbuddy.com</code>.</p>
<h2 id="heading-production-best-practices-checklist">Production Best Practices Checklist</h2>
<p>When deploying Spring Boot applications to production, follow this architectural security checklist:</p>
<h3 id="heading-1-identify-your-architecture-type">1. Identify Your Architecture Type</h3>
<ul>
<li><p><strong>Monolithic HTML Rendering (Thymeleaf, JSP):</strong> Use Synchronizer Token Pattern stored in <code>HttpSession</code>. Ensure all HTML forms include <code>_csrf</code> hidden fields.</p>
</li>
<li><p><strong>Single Page Application (React/Angular + Spring Boot API):</strong> Use Double Submit Cookie pattern (<code>CookieCsrfTokenRepository.withHttpOnlyFalse()</code>) combined with custom frontend request interceptors.</p>
</li>
<li><p><strong>Stateless Pure REST API (Machine-to-Machine / Native Mobile Apps using</strong> <code>Authorization: Bearer</code> <strong>headers):</strong> Disable CSRF (<code>.csrf(csrf -&gt; csrf.disable())</code>), because clients explicitly manage non-cookie tokens.</p>
</li>
</ul>
<h3 id="heading-2-cookie-security-flags">2. Cookie Security Flags</h3>
<p>Ensure every authentication cookie sets these attributes:</p>
<ul>
<li><p><code>Secure</code> = <code>true</code> (HTTPS only)</p>
</li>
<li><p><code>HttpOnly</code> = <code>true</code> (Prevents XSS token theft)</p>
</li>
<li><p><code>SameSite</code> = <code>Lax</code> or <code>Strict</code> (Browser-native cross-site blocking)</p>
</li>
</ul>
<h3 id="heading-3-keep-get-requests-read-only">3. Keep GET Requests Read-Only</h3>
<p>Audit your codebase to ensure no <code>@GetMapping</code> or <code>HttpServletRequest.getMethod().equals("GET")</code> handles database updates, account deletions, or password resets.</p>
<h3 id="heading-4-cross-origin-defense-layers">4. Cross-Origin Defense Layers</h3>
<p>Implement strict <code>Origin</code> and <code>Referer</code> header validation filters on state-modifying endpoints.</p>
<p>Also, deploy a robust Content Security Policy (CSP) header to reduce XSS risk (since XSS can be used to bypass CSRF defenses).</p>
<h3 id="heading-5-webhooks-and-external-callbacks">5. Webhooks and External Callbacks</h3>
<p>For server-to-server endpoints (such as Stripe or GitHub webhooks):</p>
<ul>
<li><p>Explicitly exempt webhook endpoints from standard CSRF filters in Spring Security (<code>ignoringRequestMatchers("/api/webhooks/**")</code>).</p>
</li>
<li><p>Secure webhooks using <strong>HMAC Signature Verification</strong> (<code>X-Hub-Signature-256</code>) instead of session cookies.</p>
</li>
</ul>
<h2 id="heading-final-summary-amp-defense-matrix">Final Summary &amp; Defense Matrix</h2>
<p>Cross-Site Request Forgery (CSRF) isn't a bug in browser design. It's an unintended consequence of web convenience: <strong>browsers automatically attach stored domain cookies to every outgoing request.</strong></p>
<p>When an attacker tricks a user into visiting a malicious origin (<code>evil.com</code>), the attacker relies on the browser's ambient authority to attach authenticated session credentials to a forged, state-changing request targeting your application (<code>travelbuddy.com</code>).</p>
<p>To prevent CSRF, modern web applications employ multi-layered security defenses working in tandem:</p>
<h3 id="heading-comprehensive-defense-matrix">Comprehensive Defense Matrix</h3>
<table style="min-width:125px"><colgroup><col style="min-width:25px"><col style="min-width:25px"><col style="min-width:25px"><col style="min-width:25px"><col style="min-width:25px"></colgroup><tbody><tr><td><p><strong>Defense Mechanism</strong></p></td><td><p><strong>Mechanism Layer</strong></p></td><td><p><strong>Primary Target / Action</strong></p></td><td><p><strong>Advantages</strong></p></td><td><p><strong>Limitations</strong></p></td></tr><tr><td><p><strong>Synchronizer Token Pattern</strong></p></td><td><p>Application Server</p></td><td><p>Binds unpredictable random token to server session. Verifies hidden form parameter.</p></td><td><p>Cryptographically bulletproof. Complete protection against cross-site forged requests.</p></td><td><p>Requires server-side session state (or state management).</p></td></tr><tr><td><p><strong>Double Submit Cookie Pattern</strong></p></td><td><p>Client + Server</p></td><td><p>Cookie value copied into custom HTTP header by JS. Verified server-side.</p></td><td><p>Fully stateless; ideal for SPAs (React/Angular) and microservices.</p></td><td><p>Requires non-HttpOnly cookie readable by JS. Vulnerable if subdomains are compromised.</p></td></tr><tr><td><p><code>SameSite=Lax / Strict</code><strong> Cookies</strong></p></td><td><p>Browser Engine</p></td><td><p>Instructs browser to strip cookies from cross-site requests.</p></td><td><p>Native browser enforcement. Zero server token storage required.</p></td><td><p>Legacy browser gaps. Doesn't protect state-modifying <code>GET</code> operations.</p></td></tr><tr><td><p><code>Origin</code><strong> / </strong><code>Referer</code><strong> Validation</strong></p></td><td><p>Application / Gateway</p></td><td><p>Checks incoming source headers against known server origins.</p></td><td><p>Stateless and extremely fast execution.</p></td><td><p>Headers can be stripped by privacy software/proxies.</p></td></tr><tr><td><p><strong>Bearer Tokens (</strong><code>Authorization</code><strong> Header)</strong></p></td><td><p>API Client</p></td><td><p>Token stored in <code>localStorage</code>. Attached explicitly via JS headers.</p></td><td><p>Completely immune to CSRF (no automatic browser attachment).</p></td><td><p>High risk of XSS token theft if <code>localStorage</code> is accessed by malicious scripts.</p></td></tr></tbody></table>

<p>By mastering these fundamental concepts (how browsers handle cookies, how origins operate, and how frameworks implement token validation) you can build backend architectures that are secure by design.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Production-Ready DevSecOps Platform from Homelab to AWS [Full Book] ]]>
                </title>
                <description>
                    <![CDATA[ In this book, you'll build a fintech transaction ledger from scratch and progressively transform it into a production-ready DevSecOps platform. You'll also deploy it on AWS. The app processes credit a ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-production-ready-devsecops-platform-from-homelab-to-aws-full-book/</link>
                <guid isPermaLink="false">6a67a3c3a26e578cabe00161</guid>
                
                    <category>
                        <![CDATA[ DevSecOps ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AWS ]]>
                    </category>
                
                    <category>
                        <![CDATA[ book ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Osomudeya Zudonu ]]>
                </dc:creator>
                <pubDate>Mon, 27 Jul 2026 18:30:27 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/f545c4cf-df83-4c56-a196-7b57458de9da.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>In this book, you'll build a fintech transaction ledger from scratch and progressively transform it into a production-ready DevSecOps platform. You'll also deploy it on AWS.</p>
<p>The app processes credit and debit transactions, fires compliance alerts, and stores everything in a database. You'll build the infrastructure around it yourself: automation, scanning, policy enforcement, secrets management, threat detection, and observability.</p>
<p>By the time you're finished, you'll be able to talk through every decision in an interview because you made each one.</p>
<p>This guide doesn't hand you a pre-built solution. It makes you feel out. and understand each problem before introducing the tool that solves it.</p>
<p>All the code, manifests, scripts, and stage-by-stage READMEs live in the companion repository. Clone it before you start:</p>
<pre><code class="language-bash">git clone https://github.com/Osomudeya/clearledger.git
cd clearledger
</code></pre>
<p>Everything in this book refers to files inside that repo.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>You'll need these tools installed on your machine before Stage 0:</p>
<ul>
<li><p><strong>Multipass:</strong> creates a lightweight Ubuntu VM so Kubernetes has enough resources</p>
</li>
<li><p><strong>kubectl:</strong> talks to your Kubernetes cluster from your terminal</p>
</li>
<li><p><strong>Helm:</strong> installs apps into Kubernetes</p>
</li>
<li><p><strong>Docker Desktop:</strong> builds container images</p>
</li>
<li><p><strong>jq:</strong> formats JSON output so it's readable</p>
</li>
</ul>
<p>You'll also need free accounts on GitHub and Docker Hub.</p>
<p>And you should be comfortable with the following knowledge and skills:</p>
<ul>
<li><p><strong>Basic Linux command line:</strong> navigating directories, reading files, running scripts</p>
</li>
<li><p><strong>Git:</strong> clone, commit, push</p>
</li>
<li><p>What a container is and roughly how Docker builds one</p>
</li>
</ul>
<p>You don't need prior Kubernetes, security, or cloud experience. This guide builds that from Stage 0.</p>
<p>Your machine needs at least 24 GB of RAM, 6 CPU cores, and 80 GB of free disk space. See <a href="#heading-how-to-set-up-your-machine">How to Set Up Your Machine</a> for the exact install commands.</p>
<p>The companion repo is at <a href="https://github.com/Osomudeya/clearledger">github.com/Osomudeya/clearledger</a>. Star it, clone it, then continue.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-you-are-building">What You Are Building</a></p>
</li>
<li><p><a href="#heading-how-to-work-through-this-lab">How to Work Through This Lab</a></p>
</li>
<li><p><a href="#heading-tools-you-will-use">Tools You Will Use</a></p>
</li>
<li><p><a href="#heading-how-to-choose-your-path">How to Choose Your Path</a></p>
</li>
<li><p><a href="#heading-how-to-save-your-progress">How to Save Your Progress</a></p>
</li>
<li><p><a href="#heading-who-this-is-for">Who This Is For</a></p>
</li>
<li><p><a href="#heading-how-to-set-up-your-machine">How to Set Up Your Machine</a></p>
</li>
<li><p><a href="#heading-how-to-start-the-lab">How to Start the Lab</a></p>
</li>
<li><p><a href="#heading-how-to-manage-disk-space">How to Manage Disk Space</a></p>
</li>
<li><p><a href="#heading-how-to-try-the-app-without-kubernetes">How to Try the App Without Kubernetes</a></p>
</li>
<li><p><a href="#heading-how-to-configure-local-domain-names">How to Configure Local Domain Names</a></p>
</li>
<li><p><a href="#heading-stage-0-the-running-system">Stage 0 — The Running System</a></p>
</li>
<li><p><a href="#heading-stage-1-ci-pipeline-github-actions-self-hosted-runner">Stage 1 — CI Pipeline (GitHub Actions + Self-Hosted Runner)</a></p>
</li>
<li><p><a href="#heading-stage-2-gitops-with-argocd">Stage 2 — GitOps with ArgoCD</a></p>
</li>
<li><p><a href="#heading-stage-3-security-gates">Stage 3 — Security Gates</a></p>
</li>
<li><p><a href="#heading-stage-4-admission-control-kyverno">Stage 4 — Admission Control (Kyverno)</a></p>
</li>
<li><p><a href="#heading-stage-5-secrets-management-vault">Stage 5: Secrets Management (Vault)</a></p>
</li>
<li><p><a href="#heading-stage-6-runtime-security-falco">Stage 6 — Runtime Security (Falco)</a></p>
</li>
<li><p><a href="#heading-stage-65-chaos-engineering-optional">Stage 6.5 — Chaos Engineering (Optional)</a></p>
</li>
<li><p><a href="#heading-stage-7-security-observability">Stage 7 — Security Observability</a></p>
</li>
<li><p><a href="#heading-stage-75-opentelemetry-optional">Stage 7.5 — OpenTelemetry (Optional)</a></p>
</li>
<li><p><a href="#heading-stage-8-aws-migration">Stage 8 — AWS Migration</a></p>
</li>
<li><p><a href="#heading-troubleshooting-see-troubleshootingmd">Troubleshooting (see troubleshooting.md)</a></p>
</li>
<li><p><a href="#heading-compliance-reference">Compliance Reference</a></p>
</li>
<li><p><a href="#heading-interview-preparation">Interview Preparation</a></p>
</li>
<li><p><a href="#heading-aws-cost-reference">AWS Cost Reference</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-you-are-building">What You Are Building</h2>
<p>ClearLedger is a fintech transaction ledger built with three FastAPI microservices, PostgreSQL, Redis, and a web frontend.</p>
<p>Users can register, sign in, record credit and debit transactions, view their account balance, and receive compliance alerts whenever a transaction exceeds a predefined threshold.</p>
<p>The application is intentionally simple. Its purpose isn't to teach fintech. It gives you a realistic system that you'll secure and operate like a production platform.</p>
<p>The project consists of four components:</p>
<ul>
<li><p><strong>auth-service:</strong> Handles user registration, login, and JWT authentication.</p>
</li>
<li><p><strong>ledger-service:</strong> Processes transactions, maintains account balances, and stores transaction history.</p>
</li>
<li><p><strong>notification-service:</strong> Listens for large transactions through Redis and generates compliance alerts.</p>
</li>
<li><p><strong>frontend:</strong> A web interface for logging in, viewing balances, submitting transactions, and reviewing alerts.</p>
</li>
</ul>
<p>By the end of this book, every one of these services will still exist. What changes is how they are built, deployed, secured, and operated.</p>
<p>The application is simply the vehicle. DevSecOps is the destination.</p>
<h3 id="heading-how-the-platform-evolves">How the Platform Evolves</h3>
<p>You won't install every tool on day one. Instead, the platform grows the same way production systems usually do: a problem appears first, then a solution is introduced.</p>
<p>You'll begin with a manually deployed Kubernetes application. From there, each stage solves one real operational problem.</p>
<p><strong>Stage 0: Raw Kubernetes</strong></p>
<p>You'll deploy and run the application manually, which lets you understand the system before introducing automation.</p>
<p><strong>Stage 1: Continuous Integration</strong></p>
<p>Building container images becomes automatic whenever code is pushed, eliminating manual build steps.</p>
<p><strong>Stage 2: GitOps</strong></p>
<p>Deployments are no longer done with kubectl. Git becomes the single source of truth, preventing configuration drift.</p>
<p><strong>Stage 3: Security Gates</strong></p>
<p>Every commit passes through security scanning so vulnerable code, secrets, and misconfigurations are stopped before deployment.</p>
<p><strong>Stage 4: Admission Control</strong></p>
<p>Even if something bypasses the pipeline, Kubernetes policies prevent insecure workloads from entering the cluster.</p>
<p><strong>Stage 5: Secrets Management</strong></p>
<p>Application credentials move out of Kubernetes Secrets into Vault, removing sensitive data from Git and cluster storage.</p>
<p><strong>Stage 6: Runtime Security</strong></p>
<p>Falco continuously watches running containers and detects suspicious behavior after deployment.</p>
<p><strong>Stage 6.5 (Optional): Chaos Engineering</strong></p>
<p>Failures are introduced deliberately to verify that the platform can recover instead of simply detecting problems.</p>
<p><strong>Stage 7: Observability</strong></p>
<p>Metrics, logs, and dashboards provide visibility into the health, performance, and security of the platform.</p>
<p><strong>Stage 7.5 (Optional): OpenTelemetry</strong></p>
<p>Distributed tracing follows requests across every service, revealing how a single transaction moves through the system.</p>
<p><strong>Stage 8: AWS Migration</strong></p>
<p>The same architecture is deployed on AWS using EKS, ECR, RDS, and an Application Load Balancer without changing how the application itself works.</p>
<p>If you simply want to explore the application before touching Kubernetes, an optional Docker Compose stack lets you run everything locally on your machine.</p>
<p>The guiding principle of this book is simple: every stage makes you feel the problem before introducing the tool that solves it.</p>
<h2 id="heading-how-to-work-through-this-lab">How to Work Through This Lab</h2>
<p>Throughout this process of understanding each problem before introducing the tool that solves it, three habits will carry you through every stage.</p>
<ol>
<li><p><strong>Read first before you run:</strong> The paragraphs before each command explain <em>why</em> you're running it. Skipping them means you can reproduce the steps but not explain them, and explaining them is what gets you hired. The commands are proof you understand.</p>
</li>
<li><p><strong>Choose with a reason:</strong> Every tool here solves a specific problem. Why use Vault instead of Kubernetes Secrets? Why split code and manifests into two repos? Don't just follow the steps: ask <em>what breaks if we skip this?</em> If you understand the problem, you'll remember the solution.</p>
</li>
<li><p><strong>Go in order and verify every checkpoint:</strong> Each stage depends on the one before it. When you hit an issue, read the error. Getting stuck and debugging is part of the learning: employers want to hear "I hit X error and fixed it by doing Y."</p>
</li>
</ol>
<p>At every ✋ Hands-on checkpoint:</p>
<ol>
<li><p>Run the command.</p>
</li>
<li><p>Compare your output with Expected.</p>
</li>
<li><p>If it doesn't match, fix it before continuing.</p>
</li>
<li><p>When <code>make check-N</code> passes: <code>make snapshot STAGE=N &amp;&amp; make snapshots</code>. Only continue after you see <code>clearledger.stageN</code>.</p>
</li>
</ol>
<p>Avoid these mistakes:</p>
<ul>
<li><p>Don't skip a checkpoint because it passed before.</p>
</li>
<li><p>Don't run <code>make restore</code> without checking available snapshots first.</p>
</li>
<li><p>Replace <code>your-username</code> with your real Docker Hub or GitHub username everywhere it appears.</p>
</li>
<li><p>Run runner commands inside the VM (prompt shows <code>ubuntu@clearledger</code>), not on your Mac.</p>
</li>
</ul>
<p>Take screenshots at each <strong>portfolio checkpoint</strong>. These moments become your evidence: proof that the platform runs, detects, blocks, syncs, and observes real activity.</p>
<h2 id="heading-tools-you-will-use">Tools You Will Use</h2>
<p>Come back to the below table when a new name appears and you wonder <em>why now</em>. Each entry is one line: what it does and when it appears.</p>
<p><strong>On your laptop:</strong></p>
<ul>
<li><p>Multipass creates the Ubuntu VM.</p>
</li>
<li><p>Docker builds images.</p>
</li>
<li><p><code>make</code> wraps long commands into <code>make setup</code> / <code>make check-N</code>.</p>
</li>
<li><p><code>/etc/hosts</code> entries like <code>clearledger.local</code> let your browser reach the cluster.</p>
</li>
</ul>
<p><strong>The app:</strong></p>
<ul>
<li><p>Three Python APIs (auth, ledger, notifications) + a web frontend.</p>
</li>
<li><p>Postgres stores data</p>
</li>
<li><p>Redis lets ledger publish alerts without calling notification directly</p>
</li>
<li><p>nginx ingress routes browser traffic to the right service.</p>
</li>
</ul>
<table>
<thead>
<tr>
<th>Tool</th>
<th>One-line role</th>
<th>Stage</th>
</tr>
</thead>
<tbody><tr>
<td>MicroK8s / kubectl</td>
<td>Kubernetes cluster inside the VM: <code>kubectl</code> talks to it.</td>
<td>0</td>
</tr>
<tr>
<td><code>clearledger</code> (repo)</td>
<td>App code + CI workflow: what you build.</td>
<td>1</td>
</tr>
<tr>
<td><code>clearledger-infra</code> (repo)</td>
<td>Kubernetes YAML only: what the cluster should run. CI updates it, ArgoCD deploys it.</td>
<td>1</td>
</tr>
<tr>
<td>GitHub Actions + self-hosted runner</td>
<td>Builds images and updates infra repo on every push. Runner lives in the VM to reach the local cluster.</td>
<td>1</td>
</tr>
<tr>
<td>ArgoCD</td>
<td>Watches <code>clearledger-infra</code>, syncs the cluster to match Git, reverts unauthorized changes.</td>
<td>2</td>
</tr>
<tr>
<td>Gitleaks</td>
<td>Blocks commits that contain secrets (API keys, tokens).</td>
<td>3</td>
</tr>
<tr>
<td>Semgrep</td>
<td>SAST: catches unsafe Python patterns (injection, hardcoded credentials).</td>
<td>3</td>
</tr>
<tr>
<td>Checkov</td>
<td>IaC scanning: misconfigs in Dockerfiles and Kubernetes YAML.</td>
<td>3</td>
</tr>
<tr>
<td>Trivy</td>
<td>Image scanning: known CVEs in pip/npm packages and the built container.</td>
<td>3</td>
</tr>
<tr>
<td>Syft + Grype</td>
<td>SBOM generation and vulnerability check on the artifact itself.</td>
<td>3</td>
</tr>
<tr>
<td>Cosign</td>
<td>Signs container images: Stage 4 rejects unsigned ones at deploy time.</td>
<td>3</td>
</tr>
<tr>
<td>Kyverno</td>
<td>Admission control: blocks non-compliant pods at the cluster gate (root containers, missing limits, unsigned images).</td>
<td>4</td>
</tr>
<tr>
<td>Vault</td>
<td>Stores credentials outside Git and etcd: injects them into pods via a sidecar at startup.</td>
<td>5</td>
</tr>
<tr>
<td>Falco</td>
<td>eBPF runtime detection: alerts when a shell starts or a sensitive file is read inside a running container.</td>
<td>6</td>
</tr>
<tr>
<td>Network policies</td>
<td>Kubernetes firewall between pods: limits blast radius if one service is compromised.</td>
<td>6</td>
</tr>
<tr>
<td>LitmusChaos</td>
<td>Kills pods deliberately to prove the app recovers (optional).</td>
<td>6.5</td>
</tr>
<tr>
<td>Prometheus / Grafana / Loki</td>
<td>Metrics, dashboards, and log search: turns security events into evidence.</td>
<td>7</td>
</tr>
<tr>
<td>OpenTelemetry + Tempo</td>
<td>Distributed traces: shows where one request spent its time across services (optional).</td>
<td>7.5</td>
</tr>
<tr>
<td>Terraform / EKS / ECR / RDS</td>
<td>Infrastructure as code for the AWS migration: same app, cloud-managed backing services.</td>
<td>8</td>
</tr>
</tbody></table>
<p>Each stage adds a new security layer. The tools aren't interchangeable: scanners check your code and images before deployment, ArgoCD keeps the cluster synced to Git, Vault handles secrets, Kyverno blocks unsafe workloads before they run, and Falco watches for suspicious behavior after they're running.</p>
<p>That's why the order matters: you're building defense in depth, one layer at a time.</p>
<h2 id="heading-how-to-choose-your-path">How to Choose Your Path</h2>
<p>Pick one path from your host RAM before you provision a cluster. Switching mid-lab after OOM kills or disk pressure wastes a day, so choose upfront.</p>
<table>
<thead>
<tr>
<th>Your situation</th>
<th>Path</th>
<th>What you get</th>
</tr>
</thead>
<tbody><tr>
<td><strong>8 GB RAM</strong>, or unsure this laptop can carry the lab</td>
<td><strong>Docker Compose first</strong></td>
<td>The real app: register, post a transaction, see the compliance alert fire. Then decide on a cluster. <code>make integration-up</code> · <a href="#heading-how-to-try-the-app-without-kubernetes">Local integration stack</a></td>
</tr>
<tr>
<td><strong>16 GB RAM</strong> on the host</td>
<td><strong>Lite local cluster</strong> (Stages 0–5)</td>
<td><strong>Running on one VM:</strong> This setup includes Kubernetes, CI/CD, GitOps, security checks, admission control, and Vault. To use fewer resources, edit <code>scripts/setup-cluster.local.env</code> before running <code>make setup</code>.</td>
</tr>
<tr>
<td><strong>Under 16 GB</strong> host RAM and you need Kubernetes, or you want all 8 stages</td>
<td><strong>Cloud VM</strong></td>
<td>Provision a remote machine (4–8 vCPU, 16–32 GB RAM), clone the repo, run the lab there, <code>make teardown</code> when done. Stages 6.5 / 7 / 7.5 (chaos + full observability) need 24 GB on the host, use this path if your laptop cannot spare that.</td>
</tr>
</tbody></table>
<p>The default path in this guide assumes 24 GB+ RAM and the full local VM (Before You Start). If that's not you, start from the row that matches your machine.</p>
<h2 id="heading-how-to-save-your-progress">How to Save Your Progress</h2>
<p><strong>Mac + Multipass only:</strong> <code>make snapshot</code> and <code>make restore</code> require Multipass. If you're using Linux without Multipass, skip snapshots and use Path B if something goes wrong.</p>
<p>This lab takes several days to complete.</p>
<p>Your source code lives on your computer, so rebuilding or deleting the VM doesn't delete your Git repository, commits, manifests, or configuration files.</p>
<p>The VM stores your running environment, including deployed pods, Vault secrets, Postgres data, and Grafana dashboards.</p>
<h3 id="heading-save-your-progress">Save Your Progress</h3>
<p>After completing each stage, create a snapshot before moving on. For example:</p>
<pre><code class="language-bash">make snapshot STAGE=7
make snapshots
</code></pre>
<p>Always run <code>make snapshots</code> to confirm the snapshot was created.</p>
<h3 id="heading-restore-your-progress">Restore Your Progress</h3>
<p>If the VM becomes unusable after a while, restore the latest working snapshot:</p>
<pre><code class="language-bash">make snapshots
make restore STAGE=7

export KUBECONFIG=~/.kube/clearledger-config
make check-7
</code></pre>
<h3 id="heading-what-happens-if-the-vm-breaks">What Happens If the VM Breaks?</h3>
<p>You keep:</p>
<ul>
<li><p>Your Git repository</p>
</li>
<li><p>Your commits</p>
</li>
<li><p><code>.env</code></p>
</li>
<li><p><code>setup-cluster.local.env</code></p>
</li>
<li><p><code>clearledger-infra</code> on GitHub</p>
</li>
</ul>
<p>You lose anything stored inside the VM after your last snapshot, including:</p>
<ul>
<li><p>Running pods</p>
</li>
<li><p>Vault secrets</p>
</li>
<li><p>Postgres data</p>
</li>
<li><p>Grafana and Loki data</p>
</li>
</ul>
<p>That's why it's a good idea to create a snapshot after every completed stage.</p>
<h4 id="heading-path-a-you-have-a-snapshot-recommended">Path A: You Have a Snapshot (Recommended)</h4>
<p>Restore the latest working snapshot and continue from that stage.</p>
<pre><code class="language-bash">make snapshots
make restore STAGE=6

export KUBECONFIG=~/.kube/clearledger-config
make check-6
</code></pre>
<h4 id="heading-path-b-no-snapshot">Path B: No Snapshot</h4>
<p>Rebuild the lab.</p>
<pre><code class="language-bash">make teardown
make setup

export KUBECONFIG=~/.kube/clearledger-config
</code></pre>
<p>Your Git repositories are still intact, but the Kubernetes cluster starts empty. Continue the book from the stage you had reached and rebuild the platform from there.</p>
<p>If you run into problems such as disk space issues, failed snapshots, Mac sleep or restart problems, Vault authentication errors, or pods stuck in CrashLoopBackOff, see <a href="https://github.com/Osomudeya/clearledger/blob/main/docs/troubleshooting.md">troubleshooting.md</a> for detailed recovery steps.</p>
<h2 id="heading-who-this-is-for">Who This Is For</h2>
<p><strong>Junior DevOps (0–2 yrs):</strong> do every stage in order. Don't skip the pain point sections. Expect Stage 0–2 to take a full day each, Stages 3–7 half a day each, Stage 8 a few hours. That's normal, so don't rush.</p>
<p><strong>Mid-level DevOps (2–4 yrs):</strong> skim Stages 0–2 to understand the app, focus time on Stages 3–7 where the security layers are.</p>
<p><strong>Interview preparation:</strong> complete through Stage 4, then read <code>docs/interview-prep.md</code>. The questions are based on exactly what's in this lab.</p>
<h2 id="heading-how-to-set-up-your-machine">How to Set Up Your Machine</h2>
<p>Requirements are in <a href="#heading-prerequisites">Prerequisites</a> above. Confirm 24 GB RAM, 6 CPU cores, and 80 GB free disk before installing.</p>
<h3 id="heading-install-the-required-tools">Install the Required Tools</h3>
<table>
<thead>
<tr>
<th>Tool</th>
<th>What it does</th>
<th>macOS</th>
<th>Linux</th>
<th>Windows</th>
</tr>
</thead>
<tbody><tr>
<td>Multipass</td>
<td>Creates lightweight Ubuntu VMs on your laptop</td>
<td><code>brew install --cask multipass</code></td>
<td><code>sudo snap install multipass</code></td>
<td><a href="https://multipass.run/install">multipass.run/install</a></td>
</tr>
<tr>
<td>kubectl</td>
<td>Talks to your Kubernetes cluster from your terminal</td>
<td><code>brew install kubectl</code></td>
<td><code>sudo snap install kubectl --classic</code></td>
<td><code>winget install Kubernetes.kubectl</code></td>
</tr>
<tr>
<td>Helm</td>
<td>Package manager for Kubernetes (like apt/brew but for cluster apps)</td>
<td><code>brew install helm</code></td>
<td><code>sudo snap install helm --classic</code></td>
<td><code>winget install Helm.Helm</code></td>
</tr>
<tr>
<td>Docker Desktop</td>
<td>Builds container images on your machine</td>
<td><a href="https://docs.docker.com/desktop/">docker.com</a></td>
<td><a href="https://docs.docker.com/engine/install/">docker.com</a></td>
<td><a href="https://docs.docker.com/desktop/">docker.com</a></td>
</tr>
<tr>
<td>jq</td>
<td>Formats JSON output so you can read it</td>
<td><code>brew install jq</code></td>
<td><code>sudo apt install jq</code></td>
<td><code>winget install jqlang.jq</code></td>
</tr>
</tbody></table>
<p><strong>Windows users:</strong> Run all commands inside WSL2 Ubuntu. Don't use PowerShell for this lab because the setup uses <code>make</code> and Bash scripts.</p>
<p>Verify everything before continuing:</p>
<pre><code class="language-bash">multipass --version
kubectl version --client
helm version
docker --version
jq --version
</code></pre>
<p>If any command fails, install the missing tool before continuing.</p>
<h2 id="heading-how-to-start-the-lab">How to Start the Lab</h2>
<p>The main lab path starts at <a href="#heading-stage-0-the-running-system">Stage 0: the Running System</a>.</p>
<p>After you have run the setup once step by step, you can use this shortcut next time:</p>
<pre><code class="language-bash">make setup
export KUBECONFIG=~/.kube/clearledger-config
kubectl get nodes
</code></pre>
<p>Expected: one node named <code>clearledger</code> with STATUS <code>Ready</code>.</p>
<p><code>make setup</code> provisions the Multipass VM, installs MicroK8s, applies disk-safety caps, and updates <code>/etc/hosts</code>. Takes 3–5 minutes.</p>
<h2 id="heading-how-to-manage-disk-space">How to Manage Disk Space</h2>
<p>The lab runs on a single-node MicroK8s VM with a fixed disk (80 GB by default). Over days or weeks (especially after CI builds, Helm upgrades, and Stage 7 observability) container images, logs, and journald can fill the root filesystem. Pods then fail with <code>Evicted</code>, <code>ImagePullBackOff</code>, or mysterious <code>Pending</code> states.</p>
<p><code>make setup</code> applies preventive caps automatically (log rotation, image GC thresholds, journald cap). See <a href="https://github.com/Osomudeya/clearledger/blob/main/docs/troubleshooting.md">Disk health in troubleshooting.md</a> for the full table and recovery steps.</p>
<p><strong>Check disk health:</strong></p>
<pre><code class="language-bash">make doctor    # PASS / WARN / FAIL + PVC and Prometheus TSDB sizes
</code></pre>
<p><strong>Clean up unused files inside the VM without deleting app data:</strong></p>
<pre><code class="language-bash">make reclaim
</code></pre>
<p>If <code>make doctor</code> still reports FAIL after reclaim, you may need <code>make teardown &amp;&amp; make setup</code> and restore from a snapshot. Full guidance: <a href="https://github.com/Osomudeya/clearledger/blob/main/docs/troubleshooting.md">troubleshooting.md. VM disk full</a>.</p>
<h2 id="heading-how-to-try-the-app-without-kubernetes">How to Try the App Without Kubernetes</h2>
<p>If your machine doesn't have enough resources for Kubernetes, you can run ClearLedger with Docker Compose.</p>
<pre><code class="language-bash">docker compose -f docker-compose.integration.yml up --build -d
</code></pre>
<p>Open <a href="http://localhost:3000">http://localhost:3000</a>.</p>
<p>When you're ready, stop the stack and continue with Stage 0.</p>
<pre><code class="language-bash">docker compose -f docker-compose.integration.yml down
</code></pre>
<h3 id="heading-how-to-sign-in-for-the-first-time">How to Sign In for the First Time</h3>
<p>First, you'll need to register. The database starts empty after each fresh <code>up</code> (or <code>down -v</code>). Use a real-looking email (Pydantic rejects <code>@*.local</code>), for example <code>test@clearledger.io</code> for an email and <code>SecurePass123</code> for a password.</p>
<p>Then sign in with the same credentials.</p>
<p>Wrong password shows <em>Incorrect email or password</em>. If you see a stale error, hard-refresh or run <code>localStorage.removeItem('cl_token')</code> in the browser console.</p>
<h3 id="heading-how-to-run-the-demo-flow">How to Run the Demo Flow</h3>
<p>First, register and sign in at <a href="http://localhost:3000">http://localhost:3000</a>. Submit a few credits and debits (for example, Salary +$5000, Rent −$1200).</p>
<p>Then confirm the balance updates and history lists entries.</p>
<p>Now submit a transaction <strong>≥ $10,000</strong>: the Alerts panel should show <code>LARGE_TRANSACTION</code>.</p>
<p>Here's an optional smoke test against the same base URL:</p>
<pre><code class="language-bash">BASE_URL=http://localhost:3000 bash scripts/dast/smoke.sh
</code></pre>
<h2 id="heading-how-to-configure-local-domain-names">How to Configure Local Domain Names</h2>
<p>Add the ClearLedger hostnames to your hosts file.</p>
<h3 id="heading-macos-or-linux-with-multipass">macOS or Linux with Multipass</h3>
<p>Run:</p>
<pre><code class="language-bash">sudo bash scripts/setup-hosts.sh
</code></pre>
<p>Or do it manually:</p>
<pre><code class="language-bash">VMIP=$(multipass info clearledger | grep IPv4 | awk '{print $2}')

echo "$VMIP  clearledger.local argocd.local grafana.local vault.local falco.local litmus.local" | sudo tee -a /etc/hosts
</code></pre>
<p>Verify after Stage 0:</p>
<pre><code class="language-bash">curl -s -o /dev/null -w "%{http_code}\n" http://clearledger.local/auth/health
</code></pre>
<p>Expected: <code>200</code>.</p>
<h3 id="heading-wsl2">WSL2</h3>
<p>Find your WSL IP:</p>
<pre><code class="language-bash">ip -4 addr show eth0 | grep inet
</code></pre>
<p>Use the IP shown (or <code>127.0.0.1</code> if it works on your machine), then add it to <code>/etc/hosts</code>:</p>
<pre><code class="language-bash">LAB_IP=&lt;YOUR_IP&gt;

echo "$LAB_IP  clearledger.local argocd.local grafana.local vault.local falco.local litmus.local" | sudo tee -a /etc/hosts
</code></pre>
<p>If you use Chrome or Edge on Windows instead of inside WSL, add the same line to:</p>
<p><code>C:\Windows\System32\drivers\etc\hosts</code></p>
<p>Verify:</p>
<pre><code class="language-bash">curl http://clearledger.local/auth/health
</code></pre>
<h2 id="heading-stage-0-the-running-system">Stage 0 — The Running System</h2>
<p><strong>Starting point:</strong> Nothing is deployed yet, so you're about to build a Kubernetes cluster and deploy ClearLedger manually.</p>
<p><strong>Goal:</strong> By the end of this stage, ClearLedger will be running on Kubernetes. You'll be able to register a user, submit transactions, and see compliance alerts, all deployed by hand, with no automation.</p>
<p>Every deployment, update, and fix is manual. That's intentional. Before automating a platform, you need to understand how it works without automation.</p>
<h3 id="heading-01-provision-the-cluster">0.1: Provision the Cluster</h3>
<p>Next you'll be creating a virtual machine on your laptop that runs its own Kubernetes cluster. Think of it as a miniature data center inside your computer.</p>
<p>Multipass creates lightweight Ubuntu VMs. MicroK8s is a minimal Kubernetes distribution that runs inside that VM. Together they give you a real cluster without needing cloud resources.</p>
<p><strong>Recommended: one command (do this):</strong></p>
<pre><code class="language-bash">make setup
export KUBECONFIG=~/.kube/clearledger-config
kubectl get nodes
</code></pre>
<p>Expected:</p>
<pre><code class="language-plaintext">NAME          STATUS   ROLES    AGE   VERSION
clearledger   Ready    &lt;none&gt;   2m    v1.29.x
</code></pre>
<p><code>make setup</code> runs <code>scripts/setup-cluster.sh</code> (VM + MicroK8s + disk-safety caps) and <code>scripts/setup-hosts.sh</code> (<code>/etc/hosts</code> entries). It takes 3–5 minutes.</p>
<p>Disk-safety (log rotation, image GC thresholds, journald cap) is configured automatically. See Disk health in <a href="https://github.com/Osomudeya/clearledger/blob/main/docs/troubleshooting.md">troubleshooting.md</a> for more info.</p>
<p>If STATUS is <code>NotReady</code>, wait 60 seconds and try again.</p>
<p>Here's the manual setup (only if <code>make setup</code> failed and you need to debug step by step):</p>
<pre><code class="language-bash">multipass launch \
  --name clearledger \
  --cpus 6 --memory 12G --disk 80G \
  22.04
</code></pre>
<p>Get the VM IP (needed for <code>/etc/hosts</code>):</p>
<pre><code class="language-bash">multipass info clearledger | grep IPv4
</code></pre>
<p>Add hosts entries. See the <a href="#heading-how-to-configure-local-domain-names">Domain Names</a> section above, or run <code>sudo bash scripts/setup-hosts.sh</code>.</p>
<pre><code class="language-bash">multipass shell clearledger
</code></pre>
<p>Inside the VM:</p>
<pre><code class="language-bash">sudo snap install microk8s --classic --channel=1.29/stable
sudo usermod -aG microk8s ubuntu &amp;&amp; newgrp microk8s
microk8s enable dns ingress storage helm3 rbac
echo "alias kubectl='microk8s kubectl'" &gt;&gt; ~/.bashrc
echo "alias helm='microk8s helm3'" &gt;&gt; ~/.bashrc
source ~/.bashrc
kubectl get nodes
exit   # back to your host machine
</code></pre>
<p>Connect kubectl from your host:</p>
<pre><code class="language-bash">multipass exec clearledger -- microk8s config &gt; ~/.kube/clearledger-config
export KUBECONFIG=~/.kube/clearledger-config
kubectl get nodes
</code></pre>
<h3 id="heading-02-understand-the-application-before-deploying-it">0.2: Understand the Application Before Deploying it</h3>
<p>Open these files before running a single <code>kubectl</code> command. Reading the code first builds context that makes everything else make sense.</p>
<table>
<thead>
<tr>
<th>File</th>
<th>What it does</th>
</tr>
</thead>
<tbody><tr>
<td><a href="../app/auth-service/main.py"><code>app/auth-service/main.py</code></a></td>
<td>Register, login, verify JWT</td>
</tr>
<tr>
<td><a href="../app/ledger-service/main.py"><code>app/ledger-service/main.py</code></a></td>
<td>Transactions, balance, calls auth-service to verify every request</td>
</tr>
<tr>
<td><a href="../app/notification-service/main.py"><code>app/notification-service/main.py</code></a></td>
<td>Subscribes to Redis, fires alerts when amount ≥ $10,000</td>
</tr>
<tr>
<td><a href="../app/frontend/src/app.js"><code>app/frontend/src/app.js</code></a></td>
<td>SPA: calls the same API as the curl commands</td>
</tr>
<tr>
<td><a href="../app/auth-service/Dockerfile"><code>app/auth-service/Dockerfile</code></a></td>
<td>Non-root user, pinned base image, HEALTHCHECK</td>
</tr>
</tbody></table>
<p>Notice this line in every Dockerfile: <code>USER appuser</code>. It means the image is designed to run as a normal user instead of root. The Kubernetes manifests also set <code>runAsNonRoot: true</code>. Later, in Stage 4, Kyverno enforces that rule and rejects pods that don't declare they run as non-root. Your app is prepared early so it passes that policy later.</p>
<p>Also look at <a href="../infra/manifests/auth-service/secret.yaml"><code>infra/manifests/auth-service/secret.yaml</code></a>. The database password is <code>changeme-stage0</code> encoded in base64. Decode it:</p>
<pre><code class="language-bash">echo "Y2hhbmdlbWUtc3RhZ2Uw" | base64 -d
# changeme-stage0
</code></pre>
<p>That password is sitting in a YAML file anyone with repo access can read. base64 is encoding, not encryption. It's trivially reversible. Remember this moment. It's why Stage 5 exists.</p>
<h3 id="heading-03-docker-hub-setup">0.3: Docker Hub Setup</h3>
<p>You need a container registry: a place to store the built images so the cluster can pull them. Docker Hub is the simplest option. You'll replace it with a private registry (ECR) in Stage 8.</p>
<p>Create four public repositories on Docker Hub (free account, hub.docker.com):</p>
<ol>
<li><p>Go to <a href="http://hub.docker.com"><code>hub.docker.com</code></a></p>
</li>
<li><p>Click <strong>Create repository</strong></p>
</li>
<li><p>Choose your Docker Hub username as the namespace</p>
</li>
<li><p>Enter one repository name from the list below</p>
</li>
<li><p>Set visibility to <strong>Public</strong></p>
</li>
<li><p>Click <strong>Create</strong></p>
</li>
<li><p>Repeat for all four services</p>
</li>
</ol>
<pre><code class="language-plaintext">YOUR_USERNAME/clearledger-auth-service
YOUR_USERNAME/clearledger-ledger-service
YOUR_USERNAME/clearledger-notification-service
YOUR_USERNAME/clearledger-frontend
</code></pre>
<p>Next, generate an access token. Go to hub.docker.com, then Account Settings, Security, and New Access Token (Read/Write/Delete). Save it. You won't see it again.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/85562991-e4e7-4d17-8d91-4c4ec2f60114.png" alt="image screenshot guide describing where and how to create access token" style="display: block;" width="302" height="888" loading="lazy">

<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/ad8a99a9-c6e4-41ac-a1c0-6799f731782c.png" alt="Image screenshot showing how to create access token" style="display: block;" width="1039" height="593" loading="lazy">

<pre><code class="language-bash">docker login
# Username: your Docker Hub username
# Password: the access token (NOT your account password)
</code></pre>
<p>Build and push all four services:</p>
<pre><code class="language-bash"># Replace your-username with your Docker Hub username, the same string everywhere in this lab
export DOCKER_USERNAME=your-username
echo "Using DOCKER_USERNAME=$DOCKER_USERNAME"
</code></pre>
<p><strong>✋ Hands-on checkpoint: Docker Hub username</strong></p>
<pre><code class="language-bash"># Must print your real username, not the literal text "your-username"
echo "$DOCKER_USERNAME"
</code></pre>
<p>Expected: one line with your Docker Hub name (for example, <code>veeno-demo</code>). If you see <code>your-username</code> instead, stop and fix <code>export</code> before building.</p>
<p>Build and push all four services:</p>
<pre><code class="language-bash">docker build -t $DOCKER_USERNAME/clearledger-auth-service:v0.1.0 ./app/auth-service
docker build -t $DOCKER_USERNAME/clearledger-ledger-service:v0.1.0 ./app/ledger-service
docker build -t $DOCKER_USERNAME/clearledger-notification-service:v0.1.0 ./app/notification-service
docker build -t $DOCKER_USERNAME/clearledger-frontend:v0.1.0 ./app/frontend

# Push

docker push $DOCKER_USERNAME/clearledger-auth-service:v0.1.0
docker push $DOCKER_USERNAME/clearledger-ledger-service:v0.1.0
docker push $DOCKER_USERNAME/clearledger-notification-service:v0.1.0
docker push $DOCKER_USERNAME/clearledger-frontend:v0.1.0
</code></pre>
<p><strong>✋ Hands-on checkpoint: images on Docker Hub</strong></p>
<p>Open hub.docker.com and go to your profile, then <strong>Repositories</strong>. Then confirm that all four <code>clearledger-*</code> repos exist and each shows tag <code>v0.1.0</code>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/fb07dc5f-9db2-4819-810c-89b76b01a0e1.png" alt="screenshot image confirming what docker image repo looks like when done" style="display: block;" width="776" height="397" loading="lazy">

<p>On your laptop, run:</p>
<pre><code class="language-bash">docker pull $DOCKER_USERNAME/clearledger-auth-service:v0.1.0
</code></pre>
<p>Expected: <code>Status: Downloaded newer image</code> or <code>Image is up to date</code>, not <code>repository does not exist</code> or <code>denied</code>.</p>
<h3 id="heading-04-look-at-the-manifests-before-applying-them">0.4: Look at the Manifests Before Applying Them</h3>
<p>Kubernetes uses <strong>manifest</strong> files (YAML) to describe the resources it should create. Instead of clicking buttons, you declare the desired state, and Kubernetes creates it.</p>
<p>Before deploying ClearLedger, take a quick look at these manifests:</p>
<ul>
<li><p><code>infra/manifests/namespace.yaml</code>: Creates the <code>clearledger</code> namespace.</p>
</li>
<li><p><code>infra/manifests/postgres/</code>: Deploys PostgreSQL.</p>
</li>
<li><p><code>infra/manifests/redis/redis.yaml</code>: Deploys Redis.</p>
</li>
<li><p><code>infra/manifests/auth-service/</code>: Deploys the authentication service.</p>
</li>
<li><p><code>infra/manifests/ledger-service/</code>: Deploys the ledger service.</p>
</li>
<li><p><code>infra/manifests/notification-service/</code>: Deploys the notification service.</p>
</li>
<li><p><code>infra/manifests/frontend/</code>: Deploys the web application.</p>
</li>
<li><p><code>infra/manifests/ingress.yaml</code>: Makes the application available at <code>clearledger.local</code>.</p>
</li>
<li><p><code>infra/manifests/rbac/rbac.yaml</code>: Defines who may do what inside the cluster.</p>
</li>
</ul>
<p>You don't need to understand every field yet. The goal is simply to see how the application is described before Kubernetes creates it.</p>
<p>You'll understand how Ingress routing and RBAC work in the two optional sections after §0.6. For now, just see how the app is described before Kubernetes creates it.</p>
<h3 id="heading-05-deploy-clearledger-layer-by-layer">0.5: Deploy ClearLedger (Layer by Layer)</h3>
<p>Deploy in <strong>six layers</strong>. Finish each layer before starting the next. Run <code>kubectl get pods -n clearledger</code> after layers 2, 3, and 6 to confirm progress.</p>
<p>Set a short path variable and confirm your username is still set:</p>
<pre><code class="language-bash">export DOCKER_USERNAME=your-username   # skip if already set in §0.3
STAGE0=stages/stage-0-raw-kubernetes/infra/manifests
</code></pre>
<h4 id="heading-051-layer-1-namespace-and-rbac">0.5.1 — Layer 1: Namespace and RBAC</h4>
<p>Nothing else can be created until the namespace exists. RBAC also must exist before workloads reference ServiceAccounts.</p>
<pre><code class="language-bash">kubectl apply -f infra/manifests/namespace.yaml
kubectl apply -f infra/manifests/rbac/rbac.yaml
</code></pre>
<p><strong>Verify:</strong></p>
<pre><code class="language-bash">kubectl get namespace clearledger
kubectl get serviceaccount -n clearledger
# Expected: auth-service, ledger-service, notification-service, clearledger-viewer
</code></pre>
<h4 id="heading-052-layer-2-postgresql">0.5.2 — Layer 2: PostgreSQL</h4>
<p>Database must be running before auth-service or ledger-service start. Both services connect to Postgres on startup to run migrations and serve requests, and they'll crash-loop if the database isn't there yet.</p>
<pre><code class="language-bash">kubectl apply -f infra/manifests/postgres/postgres-secret.yaml
kubectl apply -f infra/manifests/postgres/postgres.yaml

kubectl wait --for=condition=ready pod -l app=postgres \
  -n clearledger --timeout=120s
</code></pre>
<p>Expected after <code>kubectl apply</code>:</p>
<pre><code class="language-plaintext">secret/postgres-secret created
persistentvolumeclaim/postgres-pvc created
statefulset.apps/postgres created
service/postgres created
</code></pre>
<p>Expected when <code>kubectl wait</code> succeeds: the command exits with no output (exit code 0). If it times out, see <strong>If Postgres stays Pending</strong> below before continuing.</p>
<p><strong>Verify:</strong></p>
<pre><code class="language-bash">kubectl get pods -n clearledger -l app=postgres
kubectl get pvc -n clearledger
</code></pre>
<p>Expected:</p>
<pre><code class="language-plaintext">NAME         READY   STATUS    RESTARTS   AGE
postgres-0   1/1     Running   0          45s

NAME           STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS        AGE
postgres-pvc   Bound    pvc-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx   5Gi        RWO            microk8s-hostpath   45s
</code></pre>
<p><strong>If Postgres stays Pending</strong> (<code>kubectl wait</code> times out, pod shows <code>0/1 Pending</code>, PVC shows <code>Pending</code>):</p>
<p>Postgres needs a <strong>PersistentVolumeClaim</strong>: disk space on the cluster. MicroK8s provides that through the <code>hostpath-storage</code> addon. If <code>make setup</code> was interrupted or you used manual setup without <code>microk8s enable storage</code>, the PVC has nothing to bind to and the pod never schedules.</p>
<p>Check the events: you'll usually see something like:</p>
<pre><code class="language-plaintext">Warning  FailedScheduling  ...  pod has unbound immediate PersistentVolumeClaims
Normal   FailedBinding     ...  no persistent volumes available for this claim and no storage class is set
</code></pre>
<p>Fix it on the VM, then restart the postgres pod. Run this <strong>from your host</strong>: the same command on macOS, Linux, or Windows PowerShell (Multipass is installed on the host. It executes inside the VM for you):</p>
<pre><code class="language-bash"># Enable storage (and ingress/rbac if make setup skipped them)
multipass exec clearledger -- microk8s enable storage ingress rbac

# Confirm a default StorageClass exists
kubectl get storageclass
# Expected: microk8s-hostpath (default)

# Kick the pod so it reschedules against the new storage class
kubectl delete pod postgres-0 -n clearledger

kubectl wait --for=condition=ready pod -l app=postgres \
  -n clearledger --timeout=120s
kubectl get pods -n clearledger -l app=postgres
# Expected: postgres-0   1/1   Running
</code></pre>
<p>Don't continue to auth-service or ledger-service until Postgres is <code>Running</code>. They will crash-loop without a database.</p>
<h4 id="heading-053-layer-3-redis">0.5.3 — Layer 3: Redis</h4>
<p><strong>Why Redis is here (a quick scenario):</strong> Imagine a customer posts a $15,000 debit. Ledger-service saves it to Postgres, then publishes a message to Redis: <em>"large transaction, user X, amount 15000."</em> Notification-service is listening on that channel. It picks up the message and records a compliance alert: the one you'll see in the UI later when you curl <code>/notifications/alerts</code>.</p>
<p>Ledger-service and notification-service don't call each other directly. Redis sits in the middle as a <strong>message bus</strong>: ledger publishes, notification subscribes. That's why Redis must be running before you deploy notification-service (and why you deploy it now, alongside Postgres, before the app layer).</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/f099d734-8b73-483d-a28b-16cd7703703b.png" alt="flow daigram image explaining how redis works" style="display: block;" width="1536" height="1024" loading="lazy">

<pre><code class="language-bash">kubectl apply -f infra/manifests/redis/redis.yaml
</code></pre>
<p><strong>Verify:</strong></p>
<pre><code class="language-bash">kubectl get pods -n clearledger -l app=redis
</code></pre>
<p>Expected:</p>
<pre><code class="language-plaintext">NAME                     READY   STATUS    RESTARTS   AGE
redis-xxxxxxxxxx-xxxxx   1/1     Running   0          30s
</code></pre>
<h4 id="heading-054-layer-4-application-secrets">0.5.4 — Layer 4: Application secrets</h4>
<p>Credentials live in Kubernetes Secrets for Stage 0 (Stage 5 moves them to Vault).</p>
<pre><code class="language-bash">kubectl apply -f infra/manifests/auth-service/secret.yaml
kubectl apply -f infra/manifests/ledger-service/secret.yaml
</code></pre>
<p><strong>Verify:</strong></p>
<pre><code class="language-bash">kubectl get secrets -n clearledger | grep -E 'auth-service|ledger-service'
</code></pre>
<p>Expected (AGE will differ, <strong>DATA</strong> counts must match):</p>
<pre><code class="language-plaintext">auth-service-secret     Opaque   2      64s
ledger-service-secret   Opaque   1      8s
</code></pre>
<p><code>auth-service-secret</code> holds two keys (<code>database_url</code>, <code>jwt_secret</code>). <code>ledger-service-secret</code> holds one (<code>database_url</code>). Stage 5 replaces these with Vault, for now they live in the cluster as Kubernetes Secrets.</p>
<h4 id="heading-055-layer-5-application-workloads">0.5.5 — Layer 5: Application workloads</h4>
<p>You're about to start the four app services: auth, ledger, notification, and frontend. Postgres, Redis, and the Secrets from the last two layers are already in place. Now Kubernetes needs to pull your Docker Hub images and run them as pods.</p>
<p><strong>Two files per service (mostly):</strong> A Deployment tells Kubernetes <em>which container image to run</em> and <em>how many copies</em>. A <strong>Service</strong> gives that app a stable name inside the cluster (for example, <code>auth-service</code> so ledger can find auth without knowing pod IP addresses). You apply the Deployment first, then the Service.</p>
<p>So why are we using the <code>sed</code> command below? The deployment YAML files in Git contain a placeholder: literally the text <code>DOCKER_USERNAME</code>, because everyone's Docker Hub username is different. You already set yours in §0.3 (<code>export DOCKER_USERNAME=YOUR_DOCKERHUB_USERNAME</code>). The <code>sed</code> line swaps that placeholder for your real username on the fly, as the manifest is sent to Kubernetes. You never edit the file in Git. If you skip <code>sed</code> and apply the raw file, Kubernetes tries to pull an image called <code>DOCKER_USERNAME/clearledger-auth-service</code>, which doesn't exist.</p>
<p>Why do we use the Stage 0 folder? This repo has more than one copy of the Kubernetes manifests. For this manual deployment, use <code>stages/stage-0-raw-kubernetes/infra/manifests/</code>. Those files are prepared for Stage 0 and contain the <code>DOCKER_USERNAME</code> placeholder that the commands below replace. Don't use <code>infra/manifests/</code> yet, as those files are for the GitOps stages later.</p>
<p>Deploy each service in order. Run these from the repo root with <code>DOCKER_USERNAME</code> still exported:</p>
<p><strong>1. auth-service</strong>: login and registration</p>
<pre><code class="language-bash">sed "s|DOCKER_USERNAME|${DOCKER_USERNAME}|g" \
  "$STAGE0/auth-service/deployment.yaml" | kubectl apply -f -
kubectl apply -f infra/manifests/auth-service/service.yaml
</code></pre>
<p><strong>2. ledger-service</strong>: transactions and balance (needs Postgres + the secret you created in §0.5.4)</p>
<pre><code class="language-bash">sed "s|DOCKER_USERNAME|${DOCKER_USERNAME}|g" \
  "$STAGE0/ledger-service/deployment.yaml" | kubectl apply -f -
kubectl apply -f infra/manifests/ledger-service/service.yaml
</code></pre>
<p><strong>3. notification-service</strong>: listens on Redis for large-transaction alerts (no database secret in this one)</p>
<pre><code class="language-bash">sed "s|DOCKER_USERNAME|${DOCKER_USERNAME}|g" \
  "$STAGE0/notification-service/deployment.yaml" | kubectl apply -f -
kubectl apply -f infra/manifests/notification-service/service.yaml
</code></pre>
<p><strong>4. frontend</strong>: the web UI (Deployment and Service are in one file here)</p>
<pre><code class="language-bash">sed "s|DOCKER_USERNAME|${DOCKER_USERNAME}|g" \
  "$STAGE0/frontend/deployment.yaml" | kubectl apply -f -
</code></pre>
<p><strong>Verify</strong> (all app pods should reach <code>Running</code>: auth and ledger may take ~30s while they connect to Postgres):</p>
<pre><code class="language-bash">kubectl get pods -n clearledger
</code></pre>
<p>Expected. You should see Postgres and Redis from earlier layers plus new pods for each app (exact pod names vary):</p>
<pre><code class="language-plaintext">NAME                                      READY   STATUS    RESTARTS   AGE
postgres-0                                1/1     Running   0          15m
redis-xxxxxxxxxx-xxxxx                    1/1     Running   0          10m
auth-service-xxxxxxxxxx-xxxxx             1/1     Running   0          45s
auth-service-xxxxxxxxxx-xxxxx             1/1     Running   0          45s
ledger-service-xxxxxxxxxx-xxxxx           1/1     Running   0          40s
ledger-service-xxxxxxxxxx-xxxxx           1/1     Running   0          40s
notification-service-xxxxxxxxxx-xxxxx     1/1     Running   0          35s
frontend-xxxxxxxxxx-xxxxx                 1/1     Running   0          30s
</code></pre>
<p>If auth-service or ledger-service is <code>CrashLoopBackOff</code>, check the logs:</p>
<pre><code class="language-bash">kubectl logs -n clearledger deploy/auth-service --tail=20
</code></pre>
<p><strong>Common cause:</strong> you applied <code>infra/manifests/*/deployment.yaml</code> instead of the Stage 0 files above: logs may show <code>DATABASE_URL is not set</code>. Re-run the <code>sed</code> + <code>kubectl apply</code> commands in this section.</p>
<p><strong>✋ Hands-on checkpoint: workloads before ingress</strong></p>
<pre><code class="language-bash">kubectl get deployment -n clearledger
kubectl get pods -n clearledger --field-selector=status.phase!=Running
</code></pre>
<p>Expected: four Deployments (<code>auth-service</code>, <code>ledger-service</code>, <code>notification-service</code>, <code>frontend</code>) with <code>READY</code> matching desired replicas (auth and ledger show <code>2/2</code>). The second command prints <strong>nothing</strong>: no pods stuck in Pending or CrashLoopBackOff.</p>
<h4 id="heading-056-layer-6-ingress">0.5.6 — Layer 6: Ingress</h4>
<p>Exposes the cluster to <code>http://clearledger.local</code>.</p>
<pre><code class="language-bash">kubectl apply -f infra/manifests/ingress.yaml
</code></pre>
<p><strong>Verify:</strong></p>
<pre><code class="language-bash">kubectl get ingress -n clearledger
curl -s -o /dev/null -w "%{http_code}\n" http://clearledger.local/
# Expected: 200
</code></pre>
<h4 id="heading-057-watch-until-stable">0.5.7: Watch until stable</h4>
<pre><code class="language-bash">kubectl get pods -n clearledger -w
</code></pre>
<p>Expected final state (press Ctrl+C to stop watching once all pods show <code>Running</code>):</p>
<pre><code class="language-plaintext">NAME                                  READY   STATUS    RESTARTS
auth-service-xxx                      1/1     Running   0
auth-service-yyy                      1/1     Running   0
frontend-xxx                          1/1     Running   0
ledger-service-xxx                    1/1     Running   0
ledger-service-yyy                    1/1     Running   0
notification-service-xxx              1/1     Running   0
postgres-0                            1/1     Running   0
redis-xxx                             1/1     Running   0
</code></pre>
<p>Pod stuck in <code>Pending</code> or <code>CrashLoopBackOff</code>? These two commands show you what went wrong:</p>
<pre><code class="language-bash">kubectl describe pod POD_NAME -n clearledger
kubectl logs POD_NAME -n clearledger --previous
</code></pre>
<h3 id="heading-06-verify-the-running-system">0.6: Verify the Running System</h3>
<p>Use <strong>one test account</strong> for both browser and curl so nothing conflicts:</p>
<table>
<thead>
<tr>
<th>Field</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td>Email</td>
<td><code>test@clearledger.io</code></td>
</tr>
<tr>
<td>Password</td>
<td><code>SecurePass123</code></td>
</tr>
</tbody></table>
<p>If you already registered in the browser with a <strong>different</strong> password, either sign in with that password or pick a new email: the curl commands below must use the <strong>same</strong> email and password you actually registered with.</p>
<h4 id="heading-browser-verification-recommended">Browser verification (recommended):</h4>
<p>Open <code>http://clearledger.local</code> in your browser. You should see the ClearLedger login screen.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/ba678edb-ed82-4063-b2e3-304bfe31e27c.png" alt="clearledger login screen UI screenshot" style="display: block;" width="1140" height="1106" loading="lazy">

<p>Click <strong>Register</strong> and create an account with <code>test@clearledger.io</code> / <code>SecurePass123</code> (same as the curl block below. Pydantic rejects obviously fake emails like <code>test@test.com</code>).</p>
<p>Sign in with that email and password. On first login the dashboard auto-seeds demo transactions. Wait a few seconds for them to appear:</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/4bf4bd15-ad3b-466c-ba46-8539c2e1536c.png" alt="screenshot of clearledger UI after login" style="display: block;" width="1141" height="933" loading="lazy">

<p>Look at the <strong>Current Balance</strong> card. It should show a dollar amount with a sparkline chart.</p>
<p>Look at <strong>Transaction History</strong>. You should see entries like "Salary (Acme Corp", "Rent) May 2026", and so on.</p>
<p>And look at the <strong>Alerts</strong> panel at the bottom. You should see <code>LARGE_TRANSACTION</code> alerts with a red badge. Two of the demo transactions exceed $10,000, which triggers the compliance alert automatically.</p>
<p>Then submit your own transaction over $10,000 and watch the alert count increase in real time.</p>
<p><strong>What to look for:</strong></p>
<ul>
<li><p>Balance updates immediately after each transaction</p>
</li>
<li><p>Credits show as green <code>+$</code> amounts, debits show as red <code>−$</code> amounts</p>
</li>
<li><p>The Alerts badge count increases when you submit a transaction ≥ $10,000</p>
</li>
<li><p>Each alert shows the amount, direction, and timestamp</p>
</li>
</ul>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/05690dde-58de-4453-9a3f-8eee09b33293.png" alt="screenshot of clearledger UI after login and making transactions" style="display: block;" width="1473" height="1269" loading="lazy">

<p><strong>Take a screenshot of the dashboard showing transactions and at least one alert.</strong> This is the first piece of your portfolio.</p>
<p><strong>Alternatively via curl</strong> (same account: useful if the browser is not cooperating):</p>
<pre><code class="language-bash"># Register (skip if you already registered in the browser with the same email)
curl -s -X POST http://clearledger.local/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"test@clearledger.io","password":"SecurePass123"}' | jq .
</code></pre>
<p>Expected: <code>{"user_id":"...","email":"test@clearledger.io"}</code>, or an error that the email is already registered (fine if you used the browser first).</p>
<pre><code class="language-bash"># Login — save the token (must match the password you registered with)
TOKEN=$(curl -s -X POST http://clearledger.local/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"test@clearledger.io","password":"SecurePass123"}' \
  | jq -r .access_token)
echo "Token: ${TOKEN:0:30}..."
</code></pre>
<p>If <code>TOKEN</code> is empty or login returns <code>401</code>, your browser password doesn't match: re-register with the table above or use your actual password in the <code>-d</code> JSON.</p>
<pre><code class="language-bash"># Create a large transaction (triggers notification alert)
curl -s -X POST http://clearledger.local/ledger/transactions \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"amount":15000,"direction":"debit","description":"Property payment"}' | jq .
</code></pre>
<p>Expected: a transaction object with <code>id</code>, <code>amount: 15000</code>, <code>direction: "debit"</code>:</p>
<pre><code class="language-bash"># Check balance
curl -s http://clearledger.local/ledger/balance \
  -H "Authorization: Bearer $TOKEN" | jq .
</code></pre>
<pre><code class="language-bash"># Confirm the notification alert fired
curl -s http://clearledger.local/notifications/alerts | jq .
</code></pre>
<p>Expected (curl-only path, no browser demo seed): at least one alert for the $15,000 transaction, for example, <code>{"total":1,"alerts":[{"type":"LARGE_TRANSACTION","amount":15000,...}]}</code>. If you already used the browser, <code>total</code> may be <strong>3 or more</strong> (two demo alerts plus yours), that is also correct.</p>
<p><strong>If you see</strong> <code>{"detail":"Unauthorized"}</code><strong>:</strong> your token has expired. JWTs are short-lived for security. This is intentional. Re-run the login command above to get a fresh token, then retry the failed command.</p>
<p>This only affects the <code>$TOKEN</code> variable in your current terminal session. If you open a new terminal, you need to run the login command again because <code>$TOKEN</code> doesn't persist across sessions.</p>
<pre><code class="language-bash">make check-0
</code></pre>
<h3 id="heading-understanding-ingress-optional">Understanding Ingress (Optional)</h3>
<p>Read this after §0.6 if you want to understand how <code>clearledger.local</code> reaches your pods.</p>
<p>Your cluster runs four application services: frontend, auth-service, ledger-service, and notification-service. Each has an internal <strong>Service</strong> address inside the cluster, but none are reachable from your browser until an <strong>Ingress</strong> routes external traffic.</p>
<p>The Ingress is the front door. When a request hits <code>clearledger.local</code>, Kubernetes looks at the URL path and forwards to the right service. Requests to <code>/auth</code> go to auth-service, <code>/ledger</code> to ledger-service, <code>/notifications</code> to notification-service, and <code>/</code> to the frontend.</p>
<p>Open <a href="./infra/manifests/ingress.yaml"><code>infra/manifests/ingress.yaml</code></a> and read the comments. The API paths use a <strong>rewrite:</strong> <code>/auth/login</code> becomes <code>/login</code> before it reaches auth-service, so backend routes stay simple.</p>
<p>You'll add more hostnames later (<code>grafana.local</code>, <code>argocd.local</code>, and so on): each gets its own Ingress manifest in a later stage. This file is only the ClearLedger app.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/3307ca55-75d4-41be-a161-e741d2349b5e.png" alt="flow diagram explaining ingress, how it works." style="display: block;" width="1677" height="938" loading="lazy">

<h3 id="heading-understanding-rbac-optional">Understanding RBAC (Optional)</h3>
<p>Ingress controls traffic coming from outside the cluster. RBAC controls permissions inside the cluster.</p>
<p>This file creates identities and permissions for the <code>clearledger</code> namespace.</p>
<p>Open <a href="../infra/manifests/rbac/rbac.yaml"><code>infra/manifests/rbac/rbac.yaml</code></a>. The comments at the top mirror this walkthrough.</p>
<p>A <strong>ServiceAccount</strong> is an identity for a pod. For example, <code>auth-service</code>, <code>ledger-service</code>, and <code>notification-service</code> each get their own identity.</p>
<p>A <strong>Role</strong> says what that identity is allowed to do. In your repo, the app roles are very limited: they can only <code>get</code> and <code>list</code> Kubernetes Endpoints. They can't read Secrets, delete pods, create resources, or access other namespaces.</p>
<p>A <strong>RoleBinding</strong> connects the identity to the permissions. Without the RoleBinding, the Role exists but no pod receives those permissions.</p>
<p>The <code>clearledger-viewer</code> ServiceAccount is for read-only debugging. It can inspect pods, services, endpoints, events, and configmaps, but it can't read Secrets.</p>
<p>The default ServiceAccount is bound to a role with zero permissions. That way, if a pod forgets to set <code>serviceAccountName</code>, it falls back to an identity that can do nothing.</p>
<p>The point is least privilege: even if a pod is compromised, Kubernetes doesn't hand it broad cluster access.</p>
<h3 id="heading-07-why-manual-deploys-cant-be-trusted">0.7: Why Manual Deploys Can't Be Trusted</h3>
<p>In Stage 0, you built and deployed the app by hand. Now you'll make one small code change and deploy it again. This shows the problem with manual deployments: they're hard to track, hard to roll back, and hard to prove. Stages 1 and 2 fix that with CI and GitOps.</p>
<h4 id="heading-step-1-make-a-visible-change">Step 1: Make a visible change.</h4>
<p>Open <code>app/auth-service/main.py</code> and find the <code>/health</code> endpoint. Change the return value so you can tell the new version is running:</p>
<pre><code class="language-python"># Before
return {"status": "ok", "service": settings.service_name}

# After — add a version field
return {"status": "ok", "service": settings.service_name, "version": "0.2.0"}
</code></pre>
<p>Save the file. This simulates a developer shipping a small fix.</p>
<h4 id="heading-step-2-build-push-and-deploy-by-hand">Step 2: Build, push, and deploy by hand.</h4>
<pre><code class="language-bash">docker build -t $DOCKER_USERNAME/clearledger-auth-service:v0.2.0 ./app/auth-service

docker push $DOCKER_USERNAME/clearledger-auth-service:v0.2.0
kubectl set image deployment/auth-service \
  auth-service=$DOCKER_USERNAME/clearledger-auth-service:v0.2.0 \
  -n clearledger
</code></pre>
<p>Wait about 30 seconds for Kubernetes to pull the new image and restart the pods:</p>
<pre><code class="language-bash">kubectl rollout status deployment/auth-service -n clearledger
</code></pre>
<h4 id="heading-step-3-verify-your-change-is-live">Step 3: Verify your change is live.</h4>
<pre><code class="language-bash">curl -s http://clearledger.local/auth/health | jq .
</code></pre>
<p>Expected: <code>{"status":"ok","service":"auth-service","version":"0.2.0"}</code></p>
<p>If you still see the old response without <code>"version"</code>, wait a few more seconds and retry. Kubernetes is still rolling out the new pods.</p>
<h4 id="heading-step-4-notice-what-manual-deploy-doesnt-give-you">Step 4: Notice what manual deploy doesn't give you.</h4>
<p>You deployed a change. It works. But think about what just happened:</p>
<ul>
<li><p><strong>Who deployed this?</strong> There's no record. You ran <code>kubectl</code> from your laptop. If three people have cluster access, no one knows who changed what.</p>
</li>
<li><p><strong>What changed?</strong> The only evidence is the Docker Hub tag <code>v0.2.0</code>. Nothing links that tag to a specific commit or code review.</p>
</li>
<li><p><strong>What if</strong> <code>v0.2.0</code> <strong>is broken?</strong> You would need to remember the previous tag, then run <code>kubectl set image</code> again to roll back. What if you don't remember the tag? What if the previous image was deleted?</p>
</li>
<li><p><strong>What if someone else runs</strong> <code>kubectl apply</code> <strong>with</strong> <code>v0.1.0</code> <strong>while you're pushing</strong> <code>v0.2.0</code><strong>?</strong> The cluster silently reverts to the old version. No error. No notification. You think your fix is live, but it's not.</p>
</li>
<li><p><strong>Where is the audit trail?</strong> Nowhere. In a regulated environment (banking, healthcare, government), you need proof of who deployed what and when. Right now you have nothing.</p>
</li>
</ul>
<p>Manual deploys can work for a demo. But they don't hold up for a team or a regulated environment. Keep these gaps in mind. They're why the next stages exist.</p>
<h4 id="heading-step-5-revert-your-change-before-continuing">Step 5: Revert your change before continuing.</h4>
<p>Undo the health endpoint change in <code>app/auth-service/main.py</code> (remove <code>"version": "0.2.0"</code>). Don't rebuild: the cluster will keep running <code>v0.2.0</code> for now, and Stage 1 will take over image management.</p>
<p>Stage 1 automates the build. Stage 2 fixes the deployment.</p>
<h3 id="heading-what-you-learned-in-stage-0">What You Learned in Stage 0</h3>
<ul>
<li><p>How to provision a local Kubernetes cluster with Multipass and MicroK8s</p>
</li>
<li><p>How Kubernetes manifests describe the desired state of your system</p>
</li>
<li><p>How an Ingress routes external traffic to internal services</p>
</li>
<li><p>How to build, push, and deploy container images manually</p>
</li>
<li><p><strong>Why manual deploys can't be trusted</strong>: no audit trail, no rollback, no consistency</p>
</li>
</ul>
<p><strong>What you can now put on your CV / say in an interview:</strong></p>
<blockquote>
<p>Deployed a multi-service application to Kubernetes by hand: namespace, RBAC, a StatefulSet database, Deployments, Services, and path-based Ingress routing, and can explain why each layer deploys in that order.</p>
</blockquote>
<p><code>make snapshot STAGE=0 &amp;&amp; make snapshots</code>. Confirm <code>clearledger.stage0</code>. See <a href="#heading-how-to-save-your-progress">How to Save Your Progress</a>.</p>
<h2 id="heading-stage-1-ci-pipeline-github-actions-self-hosted-runner">Stage 1 — CI Pipeline (GitHub Actions + Self-Hosted Runner)</h2>
<p>In Stage 0 you built and deployed by hand. Stage 1 automates the build: a <code>git push</code> runs a pipeline that builds images, scans them, pushes to Docker Hub, and records the new tag in <code>clearledger-infra</code>.</p>
<p><strong>Goal:</strong> every push to GitHub automatically builds images, pushes them to Docker Hub, and updates image tags in <code>clearledger-infra</code>.</p>
<p><strong>Am I ready for Stage 1?</strong></p>
<p>Run these <strong>yourself</strong> before §1.1:</p>
<pre><code class="language-plaintext">make check-0
echo "$DOCKER_USERNAME"    # must not be empty or "your-username"
curl -s -o /dev/null -w "%{http_code}" http://clearledger.local/auth/health
</code></pre>
<p>Expected: health check green, <code>echo</code> prints your Docker Hub user, and curl prints <code>200</code>.</p>
<p>What you'll need for this section:</p>
<ul>
<li><p>Docker Hub account with four clearledger- repositories (see QUICKSTART.md §1b)</p>
</li>
<li><p>GitHub account: you can create repos and personal access tokens</p>
</li>
<li><p>~2–4 hours for runner install + first green pipeline (this is the hardest stage for beginners)</p>
</li>
<li><p>Done when: make check-1 passes and you manually confirmed the five items in §1.7 below. Then save: make snapshot STAGE=1 → make snapshots (confirm clearledger.stage1).</p>
</li>
</ul>
<h3 id="heading-what-you-need-to-know-first">What You Need to Know First</h3>
<p>In Stage 0, your laptop was the deployment system.</p>
<p>You typed <code>docker build</code>, <code>docker push</code>, and <code>kubectl set image</code> yourself. That worked for a demo, but it's not how teams should ship software.</p>
<p>Manual builds create too many unanswered questions:</p>
<ul>
<li><p>Did this image come from the latest code?</p>
</li>
<li><p>Did someone build it from a dirty working tree?</p>
</li>
<li><p>Did the build work the same way on another machine?</p>
</li>
<li><p>Which commit produced the image currently running?</p>
</li>
<li><p>Who pushed the image, and when?</p>
</li>
</ul>
<p><strong>CI (Continuous Integration)</strong> fixes the build side of that problem. It means that every time code is pushed, an automated system builds, checks, and packages it the same way.</p>
<p>Think of CI as a factory line:</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/5a9a4283-a77c-493e-85c4-e457b6ab00c9.png" alt="flow diagram explain gihub ci flow" style="display: block;" width="1024" height="1536" loading="lazy">

<pre><code class="language-text">Developer pushes code
        ↓
GitHub detects the push
        ↓
GitHub Actions starts the pipeline
        ↓
Runner executes the jobs
        ↓
Docker images are built and pushed
        ↓
Infra manifests are updated with the new image tags  (in clearledger-infra — §1.3)
</code></pre>
<p>The important idea is that the build no longer depends on your laptop. Your laptop writes code and the pipeline produces the release artifact.</p>
<p>A CI system has three parts:</p>
<ol>
<li><p><strong>Pipeline host</strong>: the control plane. It notices a push and decides which workflow to run. In this lab, that's <strong>GitHub Actions</strong>.</p>
</li>
<li><p><strong>Pipeline file</strong>: the instructions. It's a YAML file at <code>.github/workflows/ci.yaml</code> that says what jobs to run.</p>
</li>
<li><p><strong>Runner</strong>: the worker machine. It actually executes the commands in the pipeline.</p>
</li>
</ol>
<p>GitHub Actions normally uses GitHub-hosted runners in the cloud. In this lab, that's not enough. Your Kubernetes cluster lives inside a local Multipass VM and GitHub's cloud runner can't reach it. You also need the runner inside the VM to build Docker images using the local Docker daemon.</p>
<p>So you install a self-hosted runner inside the VM. It connects outbound to GitHub, waits for work, then executes pipeline jobs locally where it can reach everything.</p>
<p>Two repos, <code>clearledger</code> (code + CI) and <code>clearledger-infra</code> (Kubernetes YAML only). You'll create the second in §1.3.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/f7c69215-31c8-4806-b899-15caeace9485.png" alt="flow chart demonstrating self hosted github flow" style="display: block;" width="1024" height="1536" loading="lazy">

<pre><code class="language-text">GitHub — clearledger (app repo)
  stores your code
  starts the workflow on git push
        ↓
Self-hosted runner (inside Multipass VM)
  builds Docker images
  pushes images to Docker Hub
  updates image tags in clearledger-infra  ← you create this in §1.3
        ↓
GitHub — clearledger-infra (infra repo)
  stores Kubernetes YAML with the new image tags
  ArgoCD watches this repo in Stage 2 (not yet)
</code></pre>
<p>For Stages 1–7, the lab uses <code>.github/workflows/ci.yaml</code> with your self-hosted runner. It builds images, pushes them to Docker Hub, and updates <code>clearledger-infra</code>. Stage 8 adds a separate AWS workflow, <code>.github/workflows/ci-aws.yaml</code>, which pushes to ECR instead. You don't need to configure the AWS workflow until you reach Stage 8.</p>
<h3 id="heading-11-push-the-app-repo-to-github-not-clearledger-infra-yet">1.1: Push the App Repo to GitHub (Not <code>clearledger-infra</code> Yet)</h3>
<p>This step is <strong>repo #1,</strong> <code>clearledger</code> (application code + CI workflow). You're pushing the clone on your laptop: the same folder where you ran Stage 0 (<code>make setup</code>, <code>kubectl apply</code>, and so on).</p>
<p><code>clearledger-infra</code> comes later in §1.3. That second repo holds Kubernetes manifests only. Don't create it here.</p>
<p>First, put the application repo somewhere GitHub Actions can see it.</p>
<p>Go to GitHub and then New Repository:</p>
<ul>
<li><p>Repository name: <code>clearledger</code> (exact name, not <code>clearledger-infra</code>)</p>
</li>
<li><p>Visibility: <strong>Public or Private</strong>. Both work with the self-hosted runner and GitHub Actions. ArgoCD never reads this repo (see <a href="#heading-private-repos-what-syncs-where">Private repos: what syncs where</a> in §1.3).</p>
</li>
<li><p>Do <strong>not</strong> initialize with a README or <code>.gitignore</code></p>
</li>
</ul>
<p>The repo already has those files locally. If GitHub creates its own, your first push may fail because the histories don't match.</p>
<p>Run from your <strong>local</strong> <code>clearledger</code> <strong>project root</strong> on your laptop (where <code>app/</code>, <code>infra/</code>, and <code>.github/workflows/ci.yaml</code> live):</p>
<pre><code class="language-bash">cd ~/Desktop/clearledger   # your clone path
git remote add origin https://github.com/YOUR_USERNAME/clearledger.git
git branch -M main
git push -u origin main
</code></pre>
<p>If <code>git remote add</code> fails because <code>origin</code> already exists:</p>
<pre><code class="language-bash">git remote -v
git remote set-url origin https://github.com/YOUR_USERNAME/clearledger.git
git push -u origin main
</code></pre>
<p>Verify in the browser: <code>https://github.com/YOUR_USERNAME/clearledger</code>.</p>
<p>You should see <code>app/</code>, <code>infra/manifests/</code>, <code>docs/</code>, and <code>.github/workflows/ci.yaml</code>. That confirms GitHub can trigger the pipeline on your next push.</p>
<p><strong>What you proved:</strong> the <strong>app repo</strong> is on GitHub. CI will run from here. Deployment manifests for GitOps land in <code>clearledger-infra</code> in §1.3.</p>
<h3 id="heading-12-install-the-self-hosted-runner-inside-the-vm">1.2: Install the Self-Hosted Runner Inside the VM</h3>
<p>The workflow file tells GitHub <em>what</em> to run. The runner is <em>where</em> it runs.</p>
<p>This lab uses a self-hosted runner because your infrastructure is local. GitHub's cloud servers can't reach your MicroK8s cluster or Docker daemon inside the Multipass VM. The runner solves that by living inside the VM. It connects to GitHub to pick up jobs, then executes everything locally.</p>
<p>If the runner is missing or offline, the pipeline can't execute. The workflow may sit queued, or it may fail because no matching runner is available.</p>
<h4 id="heading-step-1-open-githubs-runner-setup-page-keep-this-tab-open">Step 1: Open GitHub’s runner setup page (keep this tab open)</h4>
<p>GitHub gives you a full copy-paste install guide on one page. Use it: don’t hunt for URLs or tokens elsewhere.</p>
<ol>
<li><p>Open <code>https://github.com/YOUR_USERNAME/clearledger</code></p>
</li>
<li><p>Go to Settings, Actions, Runners, and New self-hosted runner</p>
</li>
<li><p>Select Linux and x64</p>
</li>
</ol>
<p>The page title should look like: <strong>Add new self-hosted runner · YOUR_USERNAME/clearledger</strong>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/e0aa537c-a2b2-4ed7-8b48-f686a012ea8d.png" alt="e0aa537c-a2b2-4ed7-8b48-f686a012ea8d" style="display: block;" width="1251" height="1267" loading="lazy">

<p>That page has three sections you'll use:</p>
<table>
<thead>
<tr>
<th>Section on GitHub</th>
<th>What to do with it</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Download</strong></td>
<td>Copy the <code>mkdir</code>, <code>curl</code>, and <code>tar</code> commands into the VM in Step 4 (same versions as below)</td>
</tr>
<tr>
<td><strong>Configure</strong></td>
<td>Copy the <strong>token</strong> from the <code>./config.sh ... --token ...</code> line: do <strong>not</strong> run GitHub’s <code>./config.sh</code> as-is</td>
</tr>
<tr>
<td><strong>Using your self-hosted runner</strong></td>
<td>Ignore for now, the lab workflow needs the <code>clearledger</code> label (Step 4)</td>
</tr>
</tbody></table>
<p>Scroll to <strong>Configure</strong>. You'll see something like:</p>
<pre><code class="language-bash">./config.sh --url https://github.com/YOUR_USERNAME/clearledger --token AXXXXXXXXXXXXXXXXXXXXXXXXX
./run.sh
</code></pre>
<p>The token is the long string after <code>--token</code> (starts with <code>A</code>, about 26 characters). Copy only that string.</p>
<p>Keep this tab open until Step 4 finishes: the token expires in about <strong>1 hour</strong>. If it expires, click New self-hosted runner again for a fresh token.</p>
<h4 id="heading-step-2-enter-the-vm">Step 2: Enter the VM</h4>
<p><code>multipass shell clearledger</code></p>
<p>After this command, your prompt should look like <code>ubuntu@clearledger:~$</code>. That means you are inside the Ubuntu VM. If your prompt still shows your Mac username or MacBook name, you're still on your host machine and the runner setup will fail.</p>
<p>Continue only when your prompt shows <code>ubuntu@clearledger</code>.</p>
<p>Everything from Step 3 onwards runs inside the VM, not on your Mac.</p>
<h4 id="heading-step-3-install-docker-inside-the-vm">Step 3: Install Docker inside the VM</h4>
<p>The runner will build Docker images. That means Docker must exist where the runner runs.</p>
<pre><code class="language-bash">curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker ubuntu
newgrp docker

docker --version
</code></pre>
<p>Expected: Docker prints a version number (for example, <code>Docker version 29.x.x</code>).</p>
<p><strong>Verify Docker works for the</strong> <code>ubuntu</code> <strong>user now</strong>: the runner doesn't exist yet (Step 4 creates <code>~/actions-runner</code>):</p>
<pre><code class="language-bash">docker ps
</code></pre>
<p>Expected: a table header (CONTAINER ID, IMAGE, …), even if no containers are listed. <strong>Not</strong> <code>permission denied while trying to connect to the Docker API</code>.</p>
<p>If <code>docker ps</code> fails with permission denied, the <code>docker</code> group has not applied yet. Run <code>newgrp docker</code> again, or log out of the VM (<code>exit</code>) and <code>multipass shell clearledger</code> back in, then retry <code>docker ps</code>.</p>
<p><strong>What you proved:</strong> the VM can run Docker without Docker Desktop on your Mac. Continue to Step 4 to install the runner.</p>
<h4 id="heading-step-4-install-and-register-the-runner">Step 4: Install and register the runner</h4>
<p>Still inside the VM (<code>ubuntu@clearledger</code> prompt):</p>
<p><strong>Download:</strong> you can copy the commands from the <strong>Download</strong> section on GitHub’s runner page (Step 1), or run the block below. They should match. Paste into the VM, not your Mac.</p>
<p><strong>Configure:</strong> use the lab command below, not GitHub’s <code>./config.sh</code> line. Paste your token from Step 1 and replace <code>YOUR_USERNAME</code>.</p>
<pre><code class="language-bash">mkdir -p ~/actions-runner &amp;&amp; cd ~/actions-runner

curl -o actions-runner-linux-x64-2.335.1.tar.gz -L \
  https://github.com/actions/runner/releases/download/v2.335.1/actions-runner-linux-x64-2.335.1.tar.gz

tar xzf ./actions-runner-linux-x64-2.335.1.tar.gz

./config.sh \
  --url https://github.com/YOUR_USERNAME/clearledger \
  --token YOUR_RUNNER_TOKEN \
  --name clearledger-runner \
  --labels clearledger,self-hosted,linux \
  --work _work \
  --unattended

sudo ./svc.sh install
sudo ./svc.sh start
</code></pre>
<p>Do <strong>not</strong> run GitHub’s <code>./run.sh</code> for day-to-day use: the lab uses <code>sudo ./svc.sh</code> so the runner survives VM reboots. GitHub shows <code>./run.sh</code> for a quick test only.</p>
<p>Expected after <code>./config.sh</code>: <code>Runner successfully added</code> (or similar). If you see Invalid token or Expired token, go back to Step 1 in the browser and copy a fresh token.</p>
<p>The <code>clearledger</code> label is required GitHub’s default <code>./config.sh</code> on the setup page doesn't add it. The workflow uses:</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/f8fa25ff-81da-4759-b021-293c244add7c.png" alt="image showing where to add the label in github ui for the runner" style="display: block;" width="938" height="252" loading="lazy">

<pre><code class="language-yaml">runs-on: [self-hosted, clearledger]
</code></pre>
<p>GitHub schedules jobs by runner labels, not by runner name. A runner named <code>clearledger</code> without the <code>clearledger</code> label will stay online but jobs will remain queued with <code>Waiting for a runner to pick up this job</code>.</p>
<p>What those last two commands mean:</p>
<pre><code class="language-text">sudo ./svc.sh install
  Registers the runner with systemd inside the VM.
  Without this, `sudo ./svc.sh status` says: not installed.

sudo ./svc.sh start
  Starts the runner service in the background.
  After this, it keeps running even when you close the terminal.
</code></pre>
<p>Check it locally from the same folder, still inside the VM:</p>
<pre><code class="language-bash">cd ~/actions-runner
sudo ./svc.sh status
</code></pre>
<p>Expected: the service is installed and running.</p>
<p>If <code>docker ps</code> worked in Step 3 but a CI job later fails with Docker socket permission denied, the runner probably started before the <code>docker</code> group applied. Restart it after Step 4 (only when <code>~/actions-runner</code> exists):</p>
<pre><code class="language-bash">cd ~/actions-runner
sudo ./svc.sh stop
sudo ./svc.sh start
docker ps    # must work without sudo
</code></pre>
<p>Or, if you started the runner manually with <code>./run.sh</code> instead of systemd:</p>
<pre><code class="language-bash">cd ~/actions-runner
pkill -f "Runner.Listener|Runner.Worker|./run.sh" || true
nohup ./run.sh &gt; _diag/manual-runner.log 2&gt;&amp;1 &amp;
docker ps
</code></pre>
<p>If you see this:</p>
<pre><code class="language-text">not installed
</code></pre>
<p>then <code>sudo ./svc.sh install</code> didn't run successfully. Run:</p>
<pre><code class="language-bash">cd ~/actions-runner
sudo ./svc.sh install
sudo ./svc.sh start
sudo ./svc.sh status
</code></pre>
<p>If <code>install</code> fails, rerun <code>./config.sh</code> with a fresh GitHub runner token, then run the install/start commands again.</p>
<h4 id="heading-step-5-exit-the-vm">Step 5: Exit the VM</h4>
<pre><code class="language-bash">exit
</code></pre>
<h4 id="heading-step-6-verify-the-runner-is-connected">Step 6: Verify the runner is connected</h4>
<p>Go to github.com/YOUR_USERNAME/clearledger then to Settings, Actions, and Runners.</p>
<p>You should see <code>clearledger-runner</code> with a green dot and status <strong>Idle</strong>. Open the runner details and confirm the labels include:</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/f47d23ff-fecf-4789-b8ee-3be4191c1c3a.png" alt="screenshot image of github ui shpwing runner status as &quot;idle&quot; green" style="display: block;" width="816" height="589" loading="lazy">

<pre><code class="language-text">self-hosted
Linux
X64
clearledger
</code></pre>
<p>If <code>clearledger</code> is missing, add it in the runner settings before rerunning the workflow. The runner name alone is not enough.</p>
<p><strong>✋ Hands-on checkpoint: runner ready for jobs</strong></p>
<p>Still on GitHub, Settings, Actions, and Runners, confirm:</p>
<table>
<thead>
<tr>
<th>Field</th>
<th>Expected</th>
</tr>
</thead>
<tbody><tr>
<td>Status</td>
<td><strong>Idle</strong> (green)</td>
</tr>
<tr>
<td>Labels</td>
<td>includes <code>self-hosted</code> <strong>and</strong> <code>clearledger</code></td>
</tr>
<tr>
<td>OS</td>
<td>Linux</td>
</tr>
</tbody></table>
<p>Then trigger a dry run from your laptop:</p>
<pre><code class="language-bash">git commit --allow-empty -m "test: verify runner picks up jobs"
git push
</code></pre>
<p>Open <code>https://github.com/YOUR_USERNAME/clearledger/actions</code>. Within 30 seconds a workflow run should show Queued then In progress, not stuck on “Waiting for a runner.” If it waits more than 2 minutes, the labels are wrong. Edit the runner on GitHub and add <code>clearledger</code>.</p>
<p><strong>If it shows Offline:</strong></p>
<pre><code class="language-bash">multipass exec clearledger -- sudo systemctl status actions.runner.*.service
multipass exec clearledger -- journalctl -u actions.runner.*.service --lines=50
</code></pre>
<p><strong>What you proved:</strong> GitHub can now send work into your local lab environment.</p>
<h3 id="heading-13-create-the-infra-repo-on-github">1.3: Create the Infra Repo on GitHub</h3>
<p>Now separate <strong>application code</strong> from <strong>deployment state</strong>. Stage 1 introduces a second GitHub repository alongside the <code>clearledger</code> app repo you pushed in §1.1.</p>
<p>You'll use two repositories for the rest of the lab:</p>
<table>
<thead>
<tr>
<th>Repo</th>
<th>What lives there</th>
<th>Who changes it</th>
<th>Why it exists</th>
</tr>
</thead>
<tbody><tr>
<td><code>clearledger</code></td>
<td>App source code, Dockerfiles, tests, <code>.github/workflows/ci.yaml</code>, lab docs</td>
<td>You, the developer</td>
<td>This is where code changes start</td>
</tr>
<tr>
<td><code>clearledger-infra</code></td>
<td>Kubernetes manifests only: <code>deployment.yaml</code>, <code>service.yaml</code>, ingress, secrets templates</td>
<td>The CI pipeline, then ArgoCD reads it</td>
<td>This is the desired state of the cluster</td>
</tr>
</tbody></table>
<p>Think of <code>clearledger</code> as the question <em>“What is the application?”</em>. Python services, Dockerfiles, tests, and the CI workflow. Think of <code>clearledger-infra</code> as <em>“What exact version should be running in Kubernetes right now?”</em>. Deployments, Services, ingress rules, and the image tags that point at Docker Hub.</p>
<p>Teams split these on purpose. If you edit <code>README.md</code> in <code>clearledger</code>, that is a documentation change. It shouldn't trigger a deployment.<br>If you change <code>auth-service</code> code, the pipeline builds a new image (for example tag <code>abc123</code>) and, only after scans pass, records that tag in <code>clearledger-infra</code>:</p>
<pre><code class="language-yaml">image: $DOCKER_USERNAME/clearledger-auth-service:abc123
</code></pre>
<p>That line is a deployment contract: Git now says the cluster <em>should</em> run <code>abc123</code>. In Stage 1, the cluster doesn't change yet (and you'll prove that in §1.6).<br>In Stage 2, ArgoCD watches <code>clearledger-infra</code>, compares Git to what is running, and syncs the cluster when they differ. The app repo is where work begins. The infra repo is what production is supposed to look like.</p>
<h4 id="heading-private-repos-what-syncs-where">Private repos: what syncs where</h4>
<p>This lab uses two GitHub repos. <code>clearledger</code> is your main project repo: app code, CI pipeline, docs, policies, and lab files. This repo can be private.</p>
<p><code>clearledger-infra</code> contains only Kubernetes manifests. ArgoCD watches this repo and uses it to deploy the app. For beginners, make this repo public so ArgoCD can read it without extra authentication.</p>
<p>The flow looks like this:</p>
<pre><code class="language-text">clearledger
app code + infra/manifests/
        ↓
CI copies infra/manifests/
        ↓
clearledger-infra
Kubernetes manifests only
        ↓
ArgoCD syncs from this repo
        ↓
Kubernetes cluster
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/4aa15b2c-c746-4711-aa64-704a9d3eada2.png" alt="flow chart explain how both repos work" style="display: block;" width="1165" height="1350" loading="lazy">

<p>ArgoCD doesn't read the main <code>clearledger</code> repo. It only reads <code>clearledger-infra</code>. If <code>clearledger</code> is private, that is fine. If <code>clearledger-infra</code> is private, you must give ArgoCD GitHub credentials later. If you do not, ArgoCD may show <code>ComparisonError</code>.</p>
<p>Create the infra repo on GitHub:</p>
<ol>
<li><p>Go to GitHub and then <strong>New Repository</strong></p>
</li>
<li><p>Name it <code>clearledger-infra</code></p>
</li>
<li><p>Choose <strong>Public</strong></p>
</li>
<li><p>Don't add a README</p>
</li>
<li><p>Click <strong>Create</strong></p>
</li>
</ol>
<p>Later, the CI pipeline will update <code>clearledger-infra</code> automatically. In Stage 1, the pipeline doesn't run <code>kubectl apply</code> – it updates Git. In Stage 2, ArgoCD reads that Git repo and applies it to the cluster.</p>
<p><strong>Before pushing:</strong> set your Docker Hub username in Kustomize (image tags are resolved here, not in deployment YAML):</p>
<pre><code class="language-bash"># Replace YOUR_DOCKERHUB_USERNAME with the same value as $DOCKER_USERNAME from §0.3
sed -i.bak "s/YOUR_DOCKERHUB_USERNAME/${DOCKER_USERNAME}/g" infra/manifests/kustomization.yaml
rm -f infra/manifests/kustomization.yaml.bak
</code></pre>
<p>Push only the Kubernetes manifests from <code>infra/manifests/</code> (not everything under <code>infra/</code>):</p>
<pre><code class="language-bash">mkdir -p /tmp/clearledger-infra
cp -r infra/manifests /tmp/clearledger-infra/
cd /tmp/clearledger-infra
git init
git remote add origin https://github.com/YOUR_USERNAME/clearledger-infra.git
git add . &amp;&amp; git commit -m "feat: initial manifests" &amp;&amp; git push -u origin main
cd -
</code></pre>
<p><strong>✋ Hands-on checkpoint: infra repo on GitHub (do this before §1.4)</strong></p>
<p>On your laptop:</p>
<pre><code class="language-bash">grep "docker.io/${DOCKER_USERNAME}/" infra/manifests/kustomization.yaml | wc -l
grep YOUR_DOCKERHUB_USERNAME infra/manifests/kustomization.yaml || echo "OK: placeholder replaced"
</code></pre>
<p>Expected: first command prints <code>4</code> (four image lines). Second prints <code>OK: placeholder replaced</code>, not four lines still saying <code>YOUR_DOCKERHUB_USERNAME</code>.</p>
<p>In the browser, open <code>https://github.com/YOUR_USERNAME/clearledger-infra/tree/main/manifests</code> and confirm <strong>with your eyes</strong>:</p>
<table>
<thead>
<tr>
<th>File / folder</th>
<th>Must exist</th>
</tr>
</thead>
<tbody><tr>
<td><code>kustomization.yaml</code></td>
<td>Yes. Open it: <code>newName:</code> lines use <strong>your</strong> Docker Hub user</td>
</tr>
<tr>
<td><code>auth-service/secret.yaml</code></td>
<td>Yes. Stages 2–4 need this until Stage 5</td>
</tr>
<tr>
<td><code>ledger-service/secret.yaml</code></td>
<td>Yes</td>
</tr>
<tr>
<td><code>auth-service/deployment.yaml</code></td>
<td>Yes. Open it: must contain <code>secretKeyRef</code>, <strong>not</strong> <code>vault.hashicorp.com</code></td>
</tr>
<tr>
<td><code>netpol/</code></td>
<td><strong>No</strong>. If present, delete the folder on GitHub before Stage 2</td>
</tr>
<tr>
<td><code>vault/</code></td>
<td><strong>No</strong>. Vault rotation is Stage 5 only</td>
</tr>
</tbody></table>
<p><strong>Which folders matter?</strong> You only pushed <code>infra/manifests/</code> to GitHub, that's correct. Everything else in this repo stays local for now.</p>
<p>Some manifests for later stages (network policies, Vault extras) live under <code>infra/deferred-by-stage/</code> in the <code>clearledger</code> repo. You'll apply those by hand when you reach that stage. Do <strong>not</strong> copy that folder into <code>clearledger-infra</code>, or ArgoCD will deploy things too early.</p>
<p>You might notice <code>stages/stage-1-ci-pipeline/</code> has no copy of the manifests. That is normal: the lab doesn't duplicate YAML there. The canonical copy is <code>infra/manifests/</code> in this repo, and the live GitOps copy is <code>clearledger-infra</code> on GitHub.</p>
<p><strong>What you proved:</strong> Kubernetes config now has its own repo and Git history, separate from application code. CI will update <code>clearledger-infra</code> after each build, and your app repo stays for code and the pipeline file.</p>
<h3 id="heading-14-set-up-github-secrets">1.4: Set up GitHub Secrets</h3>
<p>Go to <code>github.com/YOUR_USERNAME/clearledger</code> and then Settings, Secrets and variables, Actions, and New repository secret.</p>
<p>The workflow needs credentials for Docker Hub, GitHub, and image signing:</p>
<ul>
<li><p>Docker Hub, so it can push images.</p>
</li>
<li><p>GitHub, so it can push image tag updates into <code>clearledger-infra</code>.</p>
</li>
<li><p>Cosign, so it can sign the images after pushing them.</p>
</li>
</ul>
<p>Do <strong>not</strong> paste these values into YAML files. Store them as GitHub Actions secrets.</p>
<h4 id="heading-secret-1-dockerusername">Secret 1, <code>DOCKER_USERNAME</code></h4>
<p>This is just your Docker Hub username.</p>
<p>Example:</p>
<pre><code class="language-text">veeno-demo
</code></pre>
<p>Get it from Docker Hub: hub.docker.com, profile menu, Account Settings.</p>
<h4 id="heading-secret-2-dockerpassword">Secret 2, <code>DOCKER_PASSWORD</code></h4>
<p>This should be a Docker Hub <strong>access token</strong>, not your normal Docker Hub password.</p>
<p>Create it here:</p>
<pre><code class="language-text">hub.docker.com
→ Account Settings
→ Security
→ New Access Token
→ Description: clearledger-github-actions
→ Access permissions: Read, Write, Delete or Read/Write
→ Generate
</code></pre>
<p>Copy the token immediately. Docker Hub only shows it once.</p>
<h4 id="heading-secret-3-infrarepotoken">Secret 3, <code>INFRA_REPO_TOKEN</code></h4>
<p>This is a GitHub Personal Access Token (PAT). The pipeline uses it to push commits to the second repo, <code>clearledger-infra</code>.</p>
<p>Create it here:</p>
<pre><code class="language-text">GitHub profile settings
→ Settings
→ Developer settings
→ Personal access tokens
→ Tokens (classic)
→ Click "Generate new token"
→ Choose "Generate new token (classic)"
→ If GitHub asks for your password or 2FA, complete it
→ Note: clearledger-infra-ci
→ Expiration: choose a lab-friendly value
→ Select scope: repo
   This allows the pipeline to push to clearledger-infra.
→ Generate token
</code></pre>
<p>Copy the token immediately. GitHub only shows it once.</p>
<p>For this lab, <code>repo</code> scope is the simplest option. In production, you would use tighter permissions, such as a fine-grained token limited to only <code>clearledger-infra</code>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/5a0d06bf-570a-4360-aadb-038b7ae7ed4e.png" alt="screenshot of docker ui showing where to set up PAT" style="display: block;" width="302" height="888" loading="lazy">

<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/b9e7f0c8-882c-4d15-b67b-d2ff38289836.png" alt="screenshot of docker ui showing where to set up token scope" style="display: block;" width="1039" height="593" loading="lazy">

<h4 id="heading-secrets-4-and-5-cosignprivatekey-and-cosignpassword">Secrets 4 and 5, <code>COSIGN_PRIVATE_KEY</code> and <code>COSIGN_PASSWORD</code></h4>
<p>Cosign signs container images after the pipeline pushes them to Docker Hub. Later, Stage 4 uses the public key with Kyverno so the cluster can verify that images came from your trusted pipeline.</p>
<p>Generate the key pair on your host machine, not inside the Multipass VM:</p>
<pre><code class="language-bash"># macOS: brew install cosign
# Linux/WSL2: curl -sSL -o cosign https://github.com/sigstore/cosign/releases/latest/download/cosign-linux-amd64 &amp;&amp; chmod +x cosign &amp;&amp; sudo mv cosign /usr/local/bin/
cosign generate-key-pair
</code></pre>
<p>This creates:</p>
<pre><code class="language-text">cosign.key   # private key — never commit this
cosign.pub   # public key — keep for later Kyverno verification
</code></pre>
<p>When Cosign asks for a password, enter one and save it in your password manager. If you already generated a key without a password, regenerate it with a password for this lab.</p>
<p>Add these five secrets to the <code>clearledger</code> repo, not <code>clearledger-infra</code>:</p>
<table>
<thead>
<tr>
<th>Secret name</th>
<th>Value</th>
<th>Purpose</th>
</tr>
</thead>
<tbody><tr>
<td><code>DOCKER_USERNAME</code></td>
<td>Your Docker Hub username</td>
<td>Pipeline logs in to push images</td>
</tr>
<tr>
<td><code>DOCKER_PASSWORD</code></td>
<td>Your Docker Hub access token</td>
<td>Pipeline authenticates with Docker Hub</td>
</tr>
<tr>
<td><code>INFRA_REPO_TOKEN</code></td>
<td>The GitHub PAT from above</td>
<td>Pipeline pushes image tag updates to clearledger-infra</td>
</tr>
<tr>
<td><code>COSIGN_PRIVATE_KEY</code></td>
<td>Contents of <code>cosign.key</code></td>
<td>Pipeline signs pushed container images</td>
</tr>
<tr>
<td><code>COSIGN_PASSWORD</code></td>
<td>Password used when creating the Cosign key</td>
<td>Unlocks the private key during signing</td>
</tr>
</tbody></table>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/638009a9-844b-4bcb-9701-6312ec18d5c6.png" alt="screenshot of github UI showing my repository secrets" style="display: block;" width="980" height="338" loading="lazy">

<p><strong>Repository variables (not secrets)</strong> (optional) toggles for later stages. Add under <strong>Settings, Secrets and variables, Actions, Variables</strong>:</p>
<table>
<thead>
<tr>
<th>Variable</th>
<th>Stage 1</th>
<th>When to enable</th>
</tr>
</thead>
<tbody><tr>
<td><code>ENABLE_ARGOCD_SYNC</code></td>
<td>Leave <strong>unset</strong></td>
<td><strong>Stage 2</strong> — after ArgoCD’s first sync is healthy (see <a href="#heading-how-to-enable-the-ci-to-argocd-handoff">Enable CI → ArgoCD handoff</a>)</td>
</tr>
<tr>
<td><code>ENABLE_DAST</code></td>
<td>Leave <strong>unset</strong></td>
<td><strong>Stage 3</strong> — after the app is live at <code>clearledger.local</code> (see <a href="#heading-enable-dast-optional-after-stage-2">Enable DAST</a>)</td>
</tr>
</tbody></table>
<p>Don't add either variable in Stage 1. If you set them now, CI will try to refresh ArgoCD or run ZAP before the cluster is ready, and the pipeline output gets harder to read. The guide calls out the exact moment to turn each one on – you only need to remember that both exist.</p>
<p><strong>What you proved:</strong> the pipeline can authenticate to external systems without hardcoding credentials in the repo.</p>
<h3 id="heading-15-understand-the-pipeline-before-activating-it">1.5: Understand the Pipeline Before Activating it</h3>
<p>Don't treat the workflow file as magic. Open <code>.github/workflows/ci.yaml</code> and read it before you run it.</p>
<p>The pipeline has two responsibilities:</p>
<ol>
<li><p>Prove the code and images are safe enough to publish.</p>
</li>
<li><p>Update the infra repo with the new image tags.</p>
</li>
</ol>
<p>Here's the security flow first:</p>
<pre><code class="language-text">Developer pushes code to GitHub
        ↓
GitHub Actions starts workflow
        ↓
Self-hosted runner inside the Multipass VM picks up the job
        ↓
1. Scan secrets (Gitleaks)
        ↓
2. Run code security scans (Semgrep) + IaC scan (Checkov) — parallel
        ↓
3. Prepare scanners (install Trivy/Syft/Grype/Cosign once; refresh Trivy DB once)
        ↓
4. BUILD: docker build all four services (local tags only; nothing hits Docker Hub yet)
        ↓
5. SCAN: Trivy on all images; Syft + Grype SBOM on auth-service; upload evidence
        ↓
6. PUBLISH: push to Docker Hub + Cosign sign (only if scan passed)
        ↓
7. UPDATE MANIFESTS: commit new image tags to clearledger-infra
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/0cea04bf-e166-4fd3-9eb5-1a664e427206.png" alt="visual image of the cicd security flow pattern" style="display: block;" width="1024" height="1536" loading="lazy">

<h4 id="heading-build-scan-publish-prod-style-gates">Build, scan, publish (prod-style gates)</h4>
<p>Real teams never push first and scan later. The pipeline separates three concerns into three jobs in <code>.github/workflows/ci.yaml</code>:</p>
<table>
<thead>
<tr>
<th>Job</th>
<th>What it does</th>
<th>If it fails…</th>
</tr>
</thead>
<tbody><tr>
<td><code>build-images</code></td>
<td><code>docker build</code> all services with tag <code>${{ github.sha }}</code></td>
<td>No registry pollution, images never left the runner</td>
</tr>
<tr>
<td><code>scan-images</code></td>
<td>Trivy (all 4 images); Syft + Grype (auth only)</td>
<td>Publish is skipped: bad images never reach Docker Hub</td>
</tr>
<tr>
<td><code>publish-images</code></td>
<td>Runs <code>scripts/ci-publish-image.sh</code> tag, push, Cosign sign</td>
<td>Only runs after scan passes</td>
</tr>
</tbody></table>
<p>You do <strong>not</strong> run <code>scripts/ci-publish-image.sh</code> yourself before pushing code. GitHub Actions checks out the repo and calls it inside <code>publish-images</code>.</p>
<p><strong>Why can</strong> <code>build-images</code> <strong>and</strong> <code>scan-images</code> <strong>be separate jobs?</strong> Each job is a fresh checkout on GitHub-hosted runners. They don't share a disk. On <strong>your</strong> self-hosted runner, all three jobs run on the <strong>same Multipass VM</strong> and use the <strong>same Docker engine</strong>.</p>
<p>Job 1 runs <code>docker build</code> and leaves the images on that machine. Job 2 runs Trivy against those same local images: no upload, no download. Job 3 pushes to Docker Hub only if the scan passed.</p>
<p>That's a practical lab setup: one persistent build machine with Docker installed, like a dedicated CI worker in a real office. In <strong>Stage 8 (AWS)</strong>, the pipeline uses GitHub-hosted runners instead: there, <code>build-images</code> saves the images to a file (<code>images.tar</code>) and passes that file to the next job as a workflow artifact, because those runners are throwaway VMs with no shared Docker cache.</p>
<p>Then comes the GitOps handoff:</p>
<pre><code class="language-text">Secure images now exist in Docker Hub
        ↓
Runner checks out clearledger-infra from GitHub
        ↓
Deployment YAML image tags are updated
        ↓
Runner commits and pushes back to clearledger-infra
        ↓
Stage 1 ends here
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/3be226da-a7d5-4231-8110-c37f1b8bfdce.png" alt="visual image of the cicd security flow pattern and github handoff journey" style="display: block;" width="1024" height="1536" loading="lazy">

<p><strong>Here's how the image tag ties to your code:</strong> every pipeline run is triggered by a git commit. GitHub gives that commit a unique ID called the <strong>SHA</strong> (a long hex string like <code>a1b2c3d4e5f6789…</code>). The workflow sets <code>IMAGE_TAG</code> to that SHA and uses it everywhere:</p>
<ol>
<li><p><strong>Build:</strong> <code>docker build -t clearledger-auth-service:a1b2c3d4…</code></p>
</li>
<li><p><strong>Publish:</strong> push to Docker Hub as <code>YOUR_DOCKERHUB_USERNAME/clearledger-auth-service:a1b2c3d4…</code></p>
</li>
<li><p><strong>Update manifests:</strong> <code>kustomize edit set image …:a1b2c3d4…</code> in <code>clearledger-infra</code></p>
</li>
<li><p><strong>Commit message:</strong> <code>ci: deploy a1b2c3d4… — all gates passed</code></p>
</li>
</ol>
<p>If production is running <code>YOUR_DOCKERHUB_USERNAME/clearledger-auth-service:a1b2c3d4</code>, you can copy that <code>a1b2c3d4</code> tag, open GitHub, and instantly find the exact commit that built that image. There's no guessing and no wondering if <code>latest</code> changed. Every deployed image points back to one specific version of the code, making rollbacks and debugging much easier.</p>
<h4 id="heading-the-kustomize-placeholder">The Kustomize placeholder</h4>
<p><code>auth-service/deployment.yaml</code> uses a label instead of a real image address:</p>
<pre><code class="language-yaml">image: clearledger/auth-service:gitops
</code></pre>
<p>That label isn't on Docker Hub. It tells Kustomize where to substitute. The real address lives in <code>kustomization.yaml</code>:</p>
<pre><code class="language-yaml">images:
  - name: clearledger/auth-service          # matches the label above
    newName: docker.io/YOUR_DOCKERHUB_USERNAME/clearledger-auth-service
    newTag: abc123def456…                   # real commit SHA — CI writes this
</code></pre>
<p>When ArgoCD deploys, <code>kustomize build</code> swaps the label for the full address.</p>
<p>You edit <code>kustomization.yaml</code> once in §1.3 to set your Docker Hub username in <code>newName:</code>. After that, CI writes <code>newTag:</code> automatically on every green push. You never touch it by hand.</p>
<h4 id="heading-stage-1-ci-updates-github-not-the-cluster">Stage 1: CI updates GitHub, not the cluster</h4>
<p>After a green pipeline run, three things are true:</p>
<ul>
<li><p>New images exist on Docker Hub</p>
</li>
<li><p><code>clearledger-infra</code> on GitHub has new SHAs in <code>kustomization.yaml</code></p>
</li>
<li><p>Your Kubernetes cluster is <strong>unchanged</strong>. Still running whatever Stage 0 left there</p>
</li>
</ul>
<p>CI never runs <code>kubectl apply</code>. It only commits to <code>clearledger-infra</code>. That's the whole Stage 1 lesson: build and scan are automated, but <strong>deploy</strong> is not: yet. Stage 2 installs ArgoCD, which reads <code>clearledger-infra</code> and updates the cluster for you.</p>
<p><strong>Kubernetes Checkov</strong> runs in Stage 1 but does <strong>not</strong> block the pipeline. It uploads findings so you can see hardening work ahead. Stage 4 turns those kinds of rules into cluster enforcement with Kyverno.</p>
<p>Jobs run on your self-hosted runner (<code>runs-on: [self-hosted, clearledger]</code>). Both <code>ENABLE_ARGOCD_SYNC</code> and <code>ENABLE_DAST</code> are unset in Stage 1. See §1.4 for when each gets flipped.</p>
<p>If a job fails, start with <a href="https://github.com/Osomudeya/clearledger/blob/main/docs/troubleshooting.md"><code>docs/troubleshooting.md</code></a> before editing the workflow.</p>
<h4 id="heading-stage-1-security-posture-what-blocks-vs-what-waits">Stage 1 security posture: what blocks vs what waits</h4>
<p>Note that stage 1 is not “security off.” Some gates stop the pipeline while others run for evidence and tighten in later stages.</p>
<p><strong>Blocks the pipeline today:</strong></p>
<ul>
<li><p>Gitleaks (secrets in Git)</p>
</li>
<li><p>Semgrep (SAST on Python)</p>
</li>
<li><p>Checkov on Dockerfiles</p>
</li>
<li><p>Trivy (fixable HIGH/CRITICAL CVEs in images)</p>
</li>
<li><p>Grype on auth-service SBOM (fixable HIGH+)</p>
</li>
<li><p>Manifest update to <code>clearledger-infra</code> (must succeed)</p>
</li>
</ul>
<p><strong>Runs but doesn't block yet:</strong></p>
<ul>
<li><p>Checkov on Kubernetes manifests – enforced in <strong>Stage 4</strong> (Kyverno)</p>
</li>
<li><p>Cosign sign + SLSA attest – enforced in <strong>Stage 4</strong> (unsigned images rejected)</p>
</li>
<li><p>Syft SBOM generation – supply-chain evidence. You'll purposely break gates in <strong>Stage 3.</strong></p>
</li>
<li><p>ArgoCD refresh – <strong>Stage 2</strong> (<code>ENABLE_ARGOCD_SYNC=true</code>)</p>
</li>
<li><p>DAST / ZAP – <strong>Stage 3</strong> (<code>ENABLE_DAST=true</code>)</p>
</li>
</ul>
<p><strong>If you forget which stage fixes what</strong>, search this guide for “Stage 1 security posture” or follow the stage order: Stage 3 breaks gates on purpose, Stage 4 connects Checkov findings to Kyverno, Stage 5 moves secrets off Git, Stage 6 adds runtime detection, Stage 7 adds monitoring dashboards.</p>
<p>Run <code>make check-3</code> and <code>make check-4</code> after those stages to confirm hardening landed.</p>
<p><strong>Design intent:</strong> Stage 1 proves CI can build, scan, push, and update Git without you touching Docker manually. Later stages turn evidence into enforcement. The relaxations here are deliberate.</p>
<h3 id="heading-16-activate-the-pipeline">1.6: Activate the Pipeline</h3>
<p><strong>Run this in the</strong> <code>clearledger</code> <strong>app repo, not</strong> <code>clearledger-infra</code><strong>.</strong></p>
<p>§1.3 created <code>clearledger-infra</code> with only Kubernetes manifests. It has no <code>.github/workflows/</code> and no pipeline. If your shell prompt says <code>clearledger-infra</code>, or you used <code>/tmp/clearledger-infra</code>, you're in the wrong place.</p>
<pre><code class="language-bash">cd /path/to/clearledger    # the app repo you pushed in §1.1

git remote -v              # must show .../clearledger.git — NOT clearledger-infra

ls .github/workflows/ci.yaml   # must exist before you commit
</code></pre>
<p>The pipeline file already lives at <code>.github/workflows/ci.yaml</code>. Push any small change to <code>clearledger</code> on <code>main</code>:</p>
<pre><code class="language-bash">echo "# Pipeline activated $(date)" &gt;&gt; README.md
git add README.md
git commit -m "ci: activate GitHub Actions pipeline"
git push origin main
</code></pre>
<p>Watch the run at: <code>https://github.com/YOUR_USERNAME/clearledger/actions</code> (app repo Actions tab, not the infra repo).</p>
<p>When the pipeline succeeds, it updates <code>clearledger-infra</code> for you. You don't need to push anything to the infra repo by hand for this step.</p>
<p>Expected Output: all jobs green in about 8 minutes.</p>
<pre><code class="language-plaintext">✓ Build + Scan auth-service
✓ Build + Scan ledger-service
✓ Build + Scan notification-service
✓ Build + Scan frontend
✓ Update manifests → GitHub
</code></pre>
<p>DAST and the ArgoCD refresh step show as <strong>skipped</strong>: this is expected, because Both toggles are unset until later (see §1.4).</p>
<p><strong>Note:</strong> this lab includes <code>.gitleaksignore</code> because some intentional demo secrets are already present in Git history. Gitleaks still runs normally. The ignore file only suppresses known lab fingerprints. Don't add new findings to it unless you've confirmed they're intentional test data.</p>
<p>Click into the job logs and look for the story. Don't just wait for green:</p>
<ul>
<li><p>Docker login succeeded</p>
</li>
<li><p>Each service image built and pushed to Docker Hub</p>
</li>
<li><p><code>clearledger-infra</code> was checked out</p>
</li>
<li><p>Deployment YAMLs were updated with the new SHA tag</p>
</li>
<li><p>A commit was pushed back to <code>clearledger-infra</code></p>
</li>
</ul>
<p>After the pipeline succeeds, open <code>https://github.com/YOUR_USERNAME/clearledger-infra</code> and look at the deployment manifests. The image tags should now use the current commit SHA.</p>
<p>Now check the cluster:</p>
<pre><code class="language-bash">kubectl get deployment auth-service -n clearledger \
  -o jsonpath='{.spec.template.spec.containers[0].image}' &amp;&amp; echo
</code></pre>
<p>You may still see the old image. That's expected. This is the most important learning in Stage 1:</p>
<pre><code class="language-text">GitHub pipeline succeeded.
Docker Hub has new images.
clearledger-infra has new image tags.
The Kubernetes cluster did not update automatically.
</code></pre>
<p>That's not a failure. It's the deployment gap. Stage 1 automated the build, but no controller is watching the infra repo yet. Stage 2 installs ArgoCD to close that gap.</p>
<h3 id="heading-17-hands-on-checkpoint-prove-stage-1-is-really-done">1.7 — Hands-on Checkpoint: Prove Stage 1 is Really Done</h3>
<p>Don't rely on a green workflow badge alone. Run each check yourself:</p>
<h4 id="heading-1-infra-repo-still-has-app-secrets-critical-for-stage-2">1. Infra repo still has app secrets (critical for Stage 2)</h4>
<p>Open <code>https://github.com/YOUR_USERNAME/clearledger-infra/tree/main/manifests/auth-service</code>, <code>secret.yaml</code> must be visible.</p>
<p>On your laptop:</p>
<pre><code class="language-bash">git clone --depth 1 https://github.com/YOUR_USERNAME/clearledger-infra.git /tmp/verify-infra
grep secretKeyRef /tmp/verify-infra/manifests/auth-service/deployment.yaml
grep secret.yaml /tmp/verify-infra/manifests/kustomization.yaml
rm -rf /tmp/verify-infra
</code></pre>
<p>Expected: <code>secretKeyRef</code> in deployment output. kustomization lists <code>auth-service/secret.yaml</code> and <code>ledger-service/secret.yaml</code>. If secrets are missing, re-push §1.3 manifests before Stage 2.</p>
<h4 id="heading-2-kustomize-image-tags-updated-by-ci">2. Kustomize image tags updated by CI</h4>
<pre><code class="language-bash">git clone --depth 1 https://github.com/YOUR_USERNAME/clearledger-infra.git /tmp/verify-infra
grep newTag /tmp/verify-infra/manifests/kustomization.yaml
rm -rf /tmp/verify-infra
</code></pre>
<p>Expected: <code>newTag</code> is a 40-character git SHA (or your commit hash), not still <code>v0.1.0</code> only: unless you haven't pushed since §0.3.</p>
<h4 id="heading-3-docker-hub-has-signed-images-from-this-pipeline">3. Docker Hub has signed images from this pipeline</h4>
<p>Open hub.docker.com then <code>clearledger-auth-service</code> then <strong>Tags</strong>. The latest tag should match the SHA from step 2.</p>
<h4 id="heading-4-cluster-unchanged-deployment-gap-intentional">4. (Cluster unchanged (deployment gap) intentional)</h4>
<pre><code class="language-bash">kubectl get deployment auth-service -n clearledger \
  -o jsonpath='{.spec.template.spec.containers[0].image}' &amp;&amp; echo
</code></pre>
<p>Expected: still your <strong>Stage 0</strong> tag (for example, <code>veeno-demo/clearledger-auth-service:v0.1.0</code>), not the new SHA. That proves CI didn't touch the cluster.</p>
<h4 id="heading-5-runner-still-idle">5. Runner still idle</h4>
<p>GitHub, Settings, Actions, Runners, <code>clearledger-runner</code>, <strong>Idle</strong>.</p>
<pre><code class="language-bash">make check-1
</code></pre>
<p>All five pass, onto Stage 2.</p>
<h3 id="heading-what-you-learned-in-stage-1">What You Learned in Stage 1</h3>
<ul>
<li><p><strong>CI removes your laptop from the build process.</strong> Builds become repeatable, visible, and tied to Git commits.</p>
</li>
<li><p><strong>A runner is the worker, not the pipeline itself.</strong> GitHub schedules the job, the self-hosted runner executes it inside your VM.</p>
</li>
<li><p><strong>Artifacts and desired state are different things.</strong> Docker Hub stores built images. <code>clearledger-infra</code> on GitHub stores the Kubernetes manifests that say which image should run.</p>
</li>
<li><p><strong>Good pipelines don't secretly mutate clusters.</strong> This pipeline updates Git instead of running <code>kubectl</code>.</p>
</li>
<li><p><strong>The gap that remains:</strong> the infra repo changed, but the cluster didn't. Someone still has to apply the change manually. Stage 2 fixes that with GitOps.</p>
</li>
</ul>
<p><strong>What you can now put on your CV / say in an interview:</strong></p>
<blockquote>
<p>Built a CI pipeline on a self-hosted GitHub Actions runner that builds and pushes container images on every push, and can debug a workflow that fails before any job is created.</p>
</blockquote>
<p><code>make snapshot STAGE=1 &amp;&amp; make snapshots</code>. Confirm <code>clearledger.stage1</code>. See <a href="#heading-how-to-save-your-progress">How to Save Your Progress</a>.</p>
<h2 id="heading-stage-2-gitops-with-argocd">Stage 2 — GitOps with ArgoCD</h2>
<p>From this point on, Git is in charge. Whatever is written in the infrastructure repository is what should be running. If someone changes the cluster by hand, ArgoCD notices the difference and changes it back to match Git.</p>
<p><strong>Goal:</strong> Install ArgoCD so it watches <code>clearledger-infra</code> and deploys changes to the cluster. The CI pipeline only updates the Git repository, it never connects to Kubernetes or runs <code>kubectl</code> commands.</p>
<p>Here's a more conversational, compressed version:</p>
<h3 id="heading-am-i-ready-for-stage-2">Am I ready for Stage 2?</h3>
<p>Before moving on, finish <strong>§1.6</strong>, then run:</p>
<pre><code class="language-bash">make check-1

grep secretKeyRef infra/manifests/auth-service/deployment.yaml

grep vault.hashicorp infra/manifests/auth-service/deployment.yaml &amp;&amp; echo "STOP: Vault annotations present" || echo "OK"
</code></pre>
<p>You should see:</p>
<ul>
<li><p><code>check-1</code> passes</p>
</li>
<li><p><code>secretKeyRef</code> is present</p>
</li>
<li><p><code>OK</code> (no Vault annotations yet)</p>
</li>
</ul>
<p>Quick checklist:</p>
<ul>
<li><p><code>clearledger-infra</code> contains <code>auth-service/secret.yaml</code> and <code>ledger-service/secret.yaml</code></p>
</li>
<li><p>Your self-hosted runner is <strong>Idle</strong> with the <code>clearledger</code> label</p>
</li>
<li><p><code>ENABLE_ARGOCD_SYNC</code> isn't set yet (you'll enable it after installing ArgoCD)</p>
</li>
</ul>
<p>You're done with Stage 2 when <code>make check-2</code> passes and <a href="http://argocd.local"><code>http://argocd.local</code></a> shows ArgoCD syncing <code>clearledger</code>.</p>
<p>Finally, save your progress:</p>
<pre><code class="language-bash">make snapshot STAGE=2
make snapshots
</code></pre>
<p>Confirm that <code>clearledger.stage2</code> appears in the snapshot list.</p>
<h3 id="heading-what-you-need-to-know-first">What You Need to Know First</h3>
<p><strong>The gap from Stage 1:</strong> CI already builds images and updates <code>clearledger-infra</code>. The cluster didn't change until someone ran <code>kubectl</code>. This stage closes that last step.</p>
<table>
<thead>
<tr>
<th>Who</th>
<th>Job</th>
</tr>
</thead>
<tbody><tr>
<td><strong>CI</strong> (Stage 1)</td>
<td>Build → scan → push images → update image tags in <code>clearledger-infra</code></td>
</tr>
<tr>
<td><strong>ArgoCD</strong> (Stage 2)</td>
<td>Watch <code>clearledger-infra</code> → apply manifests → cluster runs what Git says</td>
</tr>
</tbody></table>
<pre><code class="language-text">push code → CI updates clearledger-infra → ArgoCD syncs cluster
</code></pre>
<h3 id="heading-pre-sync-checklist-run-before-argocd-app-sync">Pre-sync Checklist: Run Before <code>argocd app sync</code></h3>
<p>ArgoCD applies whatever is in <code>clearledger-infra</code>. Wrong content causes red pods. Re-run the §1.7 checkpoint table to confirm GitHub-side content is still correct, then verify the laptop side:</p>
<pre><code class="language-bash"># Application manifest must point at YOUR infra repo
grep repoURL stages/stage-2-gitops/argocd/clearledger-app.yaml

# Stage 0 workloads still healthy before ArgoCD takes over
kubectl get pods -n clearledger
curl -s -o /dev/null -w "%{http_code}" http://clearledger.local/auth/health
</code></pre>
<p>Expected: <code>repoURL</code> contains your GitHub username, all app pods <code>Running</code>, curl <code>200</code>. Only when both pass should you install ArgoCD and sync below.</p>
<pre><code class="language-bash">kubectl create namespace argocd 2&gt;/dev/null || true

kubectl apply -n argocd --server-side --force-conflicts -f \
  https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml

kubectl wait --for=condition=ready pod \
  -l app.kubernetes.io/name=argocd-server -n argocd --timeout=180s
</code></pre>
<p><strong>Why</strong> <code>--server-side --force-conflicts</code><strong>?</strong> Argo CD ships a very large <code>applicationsets.argoproj.io</code> CRD. A normal <code>kubectl apply</code> tries to stash the whole thing in an annotation, hits a 256 KiB limit, and errors with <code>metadata.annotations: Too long</code>. Server-side apply avoids that. It's <a href="https://argo-cd.readthedocs.io/en/stable/operator-manual/installation/">how Argo CD expects you to install</a>.</p>
<p>Get the admin password:</p>
<pre><code class="language-bash">kubectl -n argocd get secret argocd-initial-admin-secret \
  -o jsonpath="{.data.password}" | base64 -d &amp;&amp; echo
</code></pre>
<h4 id="heading-configure-argo-cd-for-your-nginx-ingress">Configure Argo CD for your NGINX ingress</h4>
<p>The browser talks HTTPS to ingress and ingress talks plain HTTP to the Argo CD server. Without this, the UI often breaks with <code>503</code> or <code>ERR_TOO_MANY_REDIRECTS</code> on live-update URLs (<code>/api/v1/stream/*</code>).</p>
<pre><code class="language-bash">kubectl apply -f stages/stage-2-gitops/infra/argocd-cmd-params.yaml

kubectl apply -f stages/stage-2-gitops/infra/argocd-ingress.yaml

kubectl rollout restart deployment/argocd-server -n argocd

kubectl rollout status deployment/argocd-server -n argocd --timeout=180s
</code></pre>
<p><strong>Expected in</strong> <code>argocd-cmd-params-cm</code><strong>:</strong> <code>server.insecure: "true"</code>, <code>server.grpc.web: "true"</code>, <code>server.url: https://argocd.local</code>.</p>
<p>Open <code>https://argocd.local</code>. Login: <code>admin</code> and the password from above. Accept the self-signed certificate warning if the browser shows one.</p>
<p><strong>Expected:</strong> The Applications page loads. In the browser console (F12 Console), you shouldn't see <code>401</code> or <code>ERR_HTTP2_PROTOCOL_ERROR</code>. If the UI looks fine in a normal window, you're done: incognito isn't required.</p>
<p><strong>If login fails with</strong> <code>401 Unauthorized</code> (often after a config change or a bad earlier login), try a private/incognito window or clear site data for <code>argocd.local</code>, then log in again. Still stuck? See <a href="https://github.com/Osomudeya/clearledger/blob/main/docs/troubleshooting.md">troubleshooting.md. ArgoCD</a>.</p>
<p>Connect ArgoCD to the infra repo and apply the Application manifest:</p>
<h4 id="heading-1-edit-stagesstage-2-gitopsargocdclearledger-appyaml">1. Edit <code>stages/stage-2-gitops/argocd/clearledger-app.yaml</code></h4>
<p>Set <code>spec.source.repoURL</code> to your infra repo (your GitHub username, not <code>git config user.name</code>).</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/56020fa9-77fa-4d60-bcc5-bbd06b6c809f.png" alt="photo of manifest file pointing to what to change." style="display: block;" width="705" height="161" loading="lazy">

<h4 id="heading-2-connect-argocd-to-your-infrastructure-repository">2. Connect ArgoCD to your infrastructure repository:</h4>
<p>This gives ArgoCD permission to watch <code>clearledger-infra</code> for new commits. Whenever the deployment manifests change, ArgoCD will update the cluster automatically.</p>
<pre><code class="language-bash"># macOS: brew install argocd
argocd login argocd.local --username admin --password YOUR_PASSWORD --insecure --grpc-web

# Public repo
argocd repo add https://github.com/YOUR_USERNAME/clearledger-infra.git --grpc-web

# Private repo — PAT from Stage 1 §1.4 (you saved it as GitHub secret INFRA_REPO_TOKEN)
export INFRA_REPO_TOKEN='ghp_...'   # paste here; GitHub only shows it once at creation
argocd repo add https://github.com/YOUR_USERNAME/clearledger-infra.git \
  --username git --password "$INFRA_REPO_TOKEN" --grpc-web
</code></pre>
<p><strong>Verify that Argo CD can reach the repo</strong> (do this before applying the Application):</p>
<pre><code class="language-bash">argocd repo list --grpc-web
</code></pre>
<p>Look for your <code>clearledger-infra</code> URL with <strong>TYPE</strong> <code>git</code> and connection Successful. If it shows Failed or the repo is missing, Argo CD can't sync. Fix credentials before Stage 4 or any stage that depends on GitOps.</p>
<p>After a VM restore or Argo CD reinstall, you may need to run <code>argocd repo add</code> again (credentials are stored in the cluster, not in Git).</p>
<h4 id="heading-3-apply-and-sync">3. Apply and sync:</h4>
<pre><code class="language-bash">kubectl apply -f stages/stage-2-gitops/argocd/clearledger-app.yaml

argocd app sync clearledger --grpc-web
</code></pre>
<h3 id="heading-how-to-read-the-argo-cd-ui">How to Read the Argo CD UI</h3>
<p>After sync, open the <strong>clearledger</strong> application in the tree view. Three badges at the top tell you almost everything:</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/2db73788-ee70-450d-b5ed-00835d50d180.png" alt="screenshot shwoing argocd UI" style="display: block;" width="1127" height="1275" loading="lazy">

<p><strong>APP HEALTH: Healthy</strong>. Kubernetes thinks the workloads are running. Pods are up (or still starting if it says Progressing).</p>
<p><strong>SYNC STATUS: Synced</strong>: the cluster matches <code>clearledger-infra</code> on GitHub at the commit shown (for example, <code>main (2c88aa1)</code>). Git is the source of truth and Argo CD applied it.</p>
<p><strong>LAST SYNC: Succeeded</strong>: the most recent apply from Git worked. If this failed, click it for the error.</p>
<p>The resource tree below is the same app broken into pieces: namespace, secrets, services, deployments, ingress, and so on Green checkmarks = applied from Git. Click any box (for example, <code>deploy/auth-service</code>) then <strong>Live Manifest</strong> vs <strong>Desired</strong> to see what Argo CD thinks should run.</p>
<p><strong>Quick "is the app actually working?" test</strong> (outside Argo CD):</p>
<pre><code class="language-bash">curl -s -o /dev/null -w "%{http_code}\n" http://clearledger.local/auth/health
</code></pre>
<p><code>200</code> = the app is reachable end-to-end, not only "green in Argo CD."</p>
<p><strong>When something is wrong:</strong> HEALTH goes <strong>Degraded</strong> or <strong>Progressing</strong> for a long time, SYNC goes <strong>OutOfSync</strong>, and a resource in the tree turns <strong>red</strong>. Click that resource and then <strong>Events</strong> or <strong>Logs</strong>. The kubectl checks below double-check the same thing from the terminal.</p>
<p>Confirm ArgoCD is watching all workloads (not only ingress):</p>
<pre><code class="language-bash">argocd app resources clearledger --grpc-web | grep Deployment
</code></pre>
<p><strong>Pass looks like your output:</strong></p>
<pre><code class="language-text">apps    Deployment    clearledger    auth-service            No
apps    Deployment    clearledger    frontend                No
apps    Deployment    clearledger    ledger-service          No
apps    Deployment    clearledger    notification-service  No
apps    Deployment    clearledger    redis                   No
</code></pre>
<p>This command shows the Deployments that ArgoCD is managing for ClearLedger. You should see <code>auth-service</code>, <code>ledger-service</code>, <code>notification-service</code>, <code>frontend</code>, and <code>redis</code>. That means ArgoCD reads the full <code>kustomization.yaml</code> from <code>clearledger-infra/manifests</code>, not just one file.</p>
<p>The last column is <code>ORPHANED</code>. <code>No</code> is good. It means ArgoCD knows this resource belongs to the ClearLedger app. You only need to worry if one of the Deployments is missing, or if ArgoCD shows <code>OutOfSync</code>, <code>Degraded</code>, or red resources in the UI.</p>
<p><strong>✋ Hands-on checkpoint: first sync healthy</strong></p>
<p>Run these four checks. Pass looks like this:</p>
<pre><code class="language-bash">kubectl get pods -n clearledger
# Every app pod 1/1 Running (postgres/redis may show older RESTARTS from VM reboots — OK)

kubectl get application clearledger -n argocd \
  -o jsonpath='sync={.status.sync.status} health={.status.health.status}{"\n"}'
# sync=Synced health=Healthy

curl -s -o /dev/null -w "%{http_code}\n" http://clearledger.local/auth/health
# 200

kubectl logs -n clearledger deploy/auth-service --tail=5 2&gt;/dev/null | head -3
# Lines like: GET /health HTTP/1.1" 200 OK
# Bad sign: DATABASE_URL is not set
</code></pre>
<p>If all four pass then, Stage 2 first sync is done. Continue to Enable CI, and ArgoCD handoff below, then <code>make check-2</code> and <code>make snapshot STAGE=2</code>.</p>
<h3 id="heading-how-to-enable-the-ci-to-argocd-handoff">How to Enable the CI to ArgoCD Handoff</h3>
<p>In Stage 1, the pipeline updated <code>clearledger-infra</code>, but it didn't update the cluster. That was intentional.</p>
<p>Now ArgoCD is installed, so you can let the pipeline tell ArgoCD to check for changes after each successful run.</p>
<p>In GitHub, open your <code>clearledger</code> repo and go to Settings, Secrets and variables, Actions, Variables, and then New repository variable.</p>
<p>Add:</p>
<table>
<thead>
<tr>
<th><strong>Name</strong></th>
<th><strong>Value</strong></th>
</tr>
</thead>
<tbody><tr>
<td><code>ENABLE_ARGOCD_SYNC</code></td>
<td><code>true</code></td>
</tr>
</tbody></table>
<p>From now on, a green pipeline does two things:</p>
<ol>
<li><p>Updates <code>clearledger-infra</code> with the new image tag</p>
</li>
<li><p>Asks ArgoCD to sync the cluster</p>
</li>
</ol>
<p>If the pipeline can't trigger ArgoCD immediately, that's usually okay. ArgoCD checks <code>clearledger-infra</code> on its own every few minutes, so it should still pick up the new Git change.</p>
<p>Leave <code>ENABLE_DAST</code> unset for now. You enable that in Stage 3 after the app is stable at <code>clearledger.local</code>.</p>
<h3 id="heading-if-the-argocd-ui-shows-red-pods-or-progressing-read-this-before-the-screenshot">If the Argocd UI Shows Red Pods or "Progressing" (Read This Before the Screenshot)</h3>
<p>This is a common first-sync surprise, not a broken install.</p>
<h4 id="heading-why-it-happens-in-stage-2">Why it happens in Stage 2</h4>
<p>ArgoCD syncs whatever is in <code>clearledger-infra</code>. Deployments must use <code>secretKeyRef</code> (Stages 2–4), not Vault injection. If your infra repo has Vault annotations from an older lab copy, auth/ledger crash with <code>DATABASE_URL is not set</code> until Stage 5.</p>
<p>Network policies belong to Stage 6. In the main <code>clearledger</code> repo, they live in <code>infra/deferred-by-stage/stage-6-runtime-security/netpol/</code>, not in <code>infra/manifests/</code>. Don't copy them into <code>clearledger-infra</code> during Stage 2.</p>
<p>If <code>manifests/netpol/</code> is still in your <code>clearledger-infra</code> repo on GitHub (from an older copy of the lab), ArgoCD will keep applying it. Those policies use <strong>default-deny</strong> and break DNS for new pods, so you see red <strong>0/1</strong> pods and <strong>Progressing</strong> health.</p>
<h4 id="heading-fix-for-stage-2">Fix for Stage 2</h4>
<p>Do <strong>both</strong> steps. Deleting only in the cluster is not enough: ArgoCD recreates policies from Git on the next sync.</p>
<p><strong>Step 1: remove from</strong> <code>clearledger-infra</code> <strong>on GitHub</strong></p>
<p>Delete the folder <code>manifests/netpol/</code> and commit: <code>chore: defer network policies to Stage 6</code>.</p>
<p><strong>Step 2: sync and restart</strong></p>
<pre><code class="language-bash">argocd app sync clearledger --grpc-web
kubectl delete networkpolicy -n clearledger --all   # safe once Git no longer has netpol
kubectl rollout restart deployment/auth-service deployment/ledger-service -n clearledger
argocd app get clearledger --grpc-web | grep -E "Sync Status|Health Status"
</code></pre>
<p>Network policies stay in <code>clearledger</code> under <code>infra/deferred-by-stage/</code> until you apply them in Stage 6.</p>
<p>When that looks good, continue below.</p>
<p>When ArgoCD finishes syncing, open the <code>clearledger</code> app in the ArgoCD UI. You should see green <code>Healthy</code> and <code>Synced</code> badges. The app should point to your <code>clearledger-infra</code> repo, use the <code>manifests</code> path, and deploy into the <code>clearledger</code> namespace.</p>
<p>Then open the app tile. The resource tree should show your deployments, services, and ingress with no red resources.</p>
<p>You can confirm the same thing from the terminal:</p>
<p><code>argocd app get clearledger --grpc-web</code></p>
<p>Look for <code>Sync Status: Synced</code> and <code>Health Status: Healthy</code>.</p>
<h3 id="heading-argocd-stuck-outofsync">ArgoCD stuck OutOfSync</h3>
<p><strong>Normal path:</strong> CI copies full manifests + updates Kustomize tags, ArgoCD auto-syncs within ~3 minutes.</p>
<p><strong>If still OutOfSync after 10+ minutes:</strong></p>
<pre><code class="language-bash">make fix-argocd
</code></pre>
<p>This re-syncs canonical manifests to <code>clearledger-infra</code> (Kustomize SHAs preserved), re-applies the Application, and triggers a hard refresh. <strong>Don't</strong> <code>kubectl apply</code> deployments: fix Git, let ArgoCD sync.</p>
<pre><code class="language-bash">kubectl annotate application clearledger -n argocd 

argocd.argoproj.io/refresh=hard --overwrite

argocd app sync clearledger --grpc-web --prune

kubectl get application clearledger -n argocd -o jsonpath='sync={.status.sync.status} health={.status.health.status}{"\n"}'
</code></pre>
<p><strong>Take a screenshot of that view</strong>: the app tile or the resource tree is fine. That’s your portfolio proof that GitOps is actually running.</p>
<h3 id="heading-prove-argocd-self-healing">Prove ArgoCD Self-Healing</h3>
<p>Now prove that Git is the source of truth.</p>
<p>In this demo, you'll change the running cluster by hand. You will <strong>not</strong> change Git. ArgoCD should notice that the cluster no longer matches <code>clearledger-infra</code>, then change it back.</p>
<p>Before you start, make sure the app is healthy and ArgoCD is managing the deployments:</p>
<pre><code class="language-bash">argocd app resources clearledger --grpc-web | grep Deployment
</code></pre>
<p>Manually change the auth-service image in the cluster:</p>
<pre><code class="language-bash"># Manually change the image in the cluster only (Git stays the same)
kubectl set image deployment/auth-service \
  auth-service=$DOCKER_USERNAME/clearledger-auth-service:fake-tag \
  -n clearledger
</code></pre>
<p>Check ArgoCD:</p>
<pre><code class="language-bash"># ArgoCD should flip to OutOfSync within a minute or two
argocd app get clearledger --grpc-web | grep -E "Sync Status|Health Status"
</code></pre>
<p>Wait for ArgoCD to fix the cluster. The fake image tag may briefly cause an image pull error. That's expected in this demo.</p>
<pre><code class="language-bash"># Wait for selfHeal (default sync interval is ~3 minutes)
sleep 180
</code></pre>
<p>Confirm the image was changed back to the Git version:</p>
<pre><code class="language-bash"># Cluster image should match clearledger-infra again — Git was never edited
kubectl get deployment auth-service -n clearledger \
  -o jsonpath='{.spec.template.spec.containers[0].image}'
</code></pre>
<p>If the image changed back, ArgoCD self-healing worked. You changed the cluster by hand, but ArgoCD restored it to match <code>clearledger-infra</code>.</p>
<p>That's GitOps: Git says what should run, and ArgoCD keeps the cluster matching Git.</p>
<pre><code class="language-bash">make check-2
</code></pre>
<h3 id="heading-how-to-roll-back-a-bad-deploy">How to Roll Back a Bad Deploy</h3>
<p>You just proved that ArgoCD reverts unauthorized cluster changes. Now flip it: <strong>what if you pushed a bad commit yourself?</strong> GitOps rollback isn't a button. It's a Git operation. This section explains why, shows you both methods, and has you practice each one before you need them under pressure.</p>
<h4 id="heading-how-you-know-you-need-to-roll-back">How you know you need to roll back</h4>
<p>These symptoms appearing within minutes of a push to <code>clearledger-infra</code> point at a bad commit:</p>
<ul>
<li><p>Pods stuck in <code>CrashLoopBackOff</code> or <code>Error</code>. Check with <code>kubectl get pods -n clearledger</code>.</p>
</li>
<li><p><code>kubectl logs &lt;pod&gt; -n clearledger --previous</code> shows startup errors that weren't there before.</p>
</li>
<li><p>ArgoCD health flips from <code>Healthy</code> to <code>Degraded</code> or stays on <code>Progressing</code>. Check with <code>argocd app get clearledger --grpc-web</code>.</p>
</li>
<li><p>The app returns 5xx errors or login stops working. Check with <code>curl -I http://clearledger.local/health</code>.</p>
</li>
</ul>
<p>If this happens right after a push, roll back first. Once the app is stable again, investigate the bad commit.</p>
<h4 id="heading-why-argocd-rollback-isnt-just-a-button">Why ArgoCD rollback isn't just a button</h4>
<p>ArgoCD has a rollback button in the UI and an <code>argocd app rollback</code> command. Both work. But only if you understand the interaction with <code>selfHeal</code>.</p>
<p>Your Application (<code>stages/stage-2-gitops/argocd/clearledger-app.yaml</code>) is configured with:</p>
<pre><code class="language-yaml">syncPolicy:
  automated:
    selfHeal: true
</code></pre>
<p>ArgoCD keeps the cluster matched to Git. In this lab, Git means <code>clearledger-infra</code>.</p>
<p>If someone changes the cluster by hand, ArgoCD treats that as drift and changes it back to match Git.</p>
<p>This also affects rollback. The ArgoCD UI rollback changes the cluster, but it doesn't change Git. If <code>clearledger-infra</code> still points to the bad version, and self-heal can bring the bad version back.</p>
<p>The safer GitOps rollback is to change Git with <code>git revert</code> in <code>clearledger-infra</code>. Then ArgoCD syncs the cluster to the reverted, good version.</p>
<p>If you need an emergency UI rollback, turn off auto-sync first, roll back in ArgoCD, then fix Git afterward.</p>
<h4 id="heading-method-1-git-revert-preferred-always-try-this-first">Method 1: Git revert (preferred, always try this first)</h4>
<p>This is the GitOps way. You don't touch the cluster. You change Git, and ArgoCD syncs the fix.</p>
<p><strong>When to use:</strong> You have a few minutes and can identify the bad commit in <code>clearledger-infra</code>.</p>
<p><strong>How it works:</strong></p>
<pre><code class="language-plaintext">Bad commit pushed to clearledger-infra
        ↓
ArgoCD auto-synced it (cluster is now broken)
        ↓
You run: git revert &lt;bad-commit&gt; &amp;&amp; git push
        ↓
ArgoCD auto-syncs the revert (cluster is fixed, selfHeal works with you)
        ↓
Git history shows the bad deploy AND the revert, full audit trail
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/2371fab2-b05d-4453-ac28-80465a95a88f.png" alt="demo showing how argocd works and the flow" style="display: block;" width="1024" height="1536" loading="lazy">

<p><strong>Step-by-step:</strong></p>
<pre><code class="language-bash"># 1. Go to your clearledger-infra repo (wherever you cloned it)
cd ~/clearledger-infra  # adjust path if you cloned elsewhere
git pull                # make sure you are up to date

# 2. Find the bad commit
git log --oneline -10

# Output looks like:
# abc1234 update ledger-service image to v1.4.0   ← this broke prod
# def5678 update auth-service image to v1.3.1     ← was fine
# 9a1b2c3 add vault rotation cronjob

# 3. Revert it — this creates a NEW commit, it does not delete history
git revert abc1234 --no-edit

# 4. Push — ArgoCD picks it up automatically within ~3 minutes
git push

# 5. Confirm the cluster recovered
kubectl get pods -n clearledger
argocd app get clearledger --grpc-web | grep -E "Sync Status|Health Status"
# Expected: Sync Status: Synced, Health Status: Healthy
</code></pre>
<p>This method is preferred because it fixes the source of truth: <code>clearledger-infra</code>.</p>
<p>After you push the revert, ArgoCD sees the new Git state and syncs the cluster to it. Nothing fights you because Git and the cluster are supposed to match.</p>
<p>It also leaves a clear history. Git shows the bad deploy, the revert, who made both changes, and when they happened. That's easier to debug, easier to review, and better for compliance.</p>
<h4 id="heading-method-2-emergency-argocd-rollback-when-the-cluster-is-on-fire">Method 2: Emergency ArgoCD rollback (when the cluster is on fire)</h4>
<p>Use this if the cluster is broken right now and you don't have time to push a Git fix. It pins the cluster to a previous known good deployment immediately. You'll still fix Git afterward. This isn't a permanent fix.</p>
<p><strong>When to use:</strong> Incident in progress. Pods are crashing, users are affected, and you need the cluster back to a known good state in under 30 seconds.</p>
<p><strong>Before you start:</strong> confirm your ArgoCD CLI session is still valid. If it expired, re-login first: an expired session will silently fail every command below.</p>
<blockquote>
<pre><code class="language-bash">argocd account get-user-info --grpc-web
# If you see "Unauthenticated", re-login:
ARGOCD_PASSWORD=$(kubectl -n argocd get secret argocd-initial-admin-secret \
  -o jsonpath="{.data.password}" | base64 -d)

argocd login argocd.local --username admin --password "$ARGOCD_PASSWORD" \
  --insecure --grpc-web
</code></pre>
</blockquote>
<p><strong>Step 1: Disable auto-sync</strong> (critical). Skip this and selfHeal will undo your rollback within 3 minutes.</p>
<pre><code class="language-bash">argocd app set clearledger --sync-policy none --grpc-web
# Confirm: automated sync is now off
argocd app get clearledger --grpc-web | grep "Sync Policy"
# Expected: Sync Policy: &lt;none&gt;
</code></pre>
<p><strong>Step 2: Find the last known-good deployment ID</strong></p>
<pre><code class="language-bash">argocd app history clearledger --grpc-web

# Output looks like:
# ID   DATE                           REVISION
# 9    2026-06-05 10:12:00 +0000 UTC  abc1234  ← bad deploy (current)
# 8    2026-06-04 14:46:06 +0000 UTC  def5678  ← known good
# 7    2026-06-01 20:53:19 +0000 UTC  9a1b2c3

# Or check via kubectl (no argocd CLI needed):
kubectl get application clearledger -n argocd \
  -o jsonpath='{range .status.history[*]}{.id}{"\t"}{.deployedAt}{"\t"}{.revision}{"\n"}{end}'
</code></pre>
<p>Use the ID (the number on the left), not the SHA.</p>
<p><strong>Step 3: Roll back to the good ID</strong></p>
<pre><code class="language-bash">argocd app rollback clearledger 8 --grpc-web
</code></pre>
<p><strong>Step 4: Confirm the cluster is stable</strong></p>
<pre><code class="language-bash">kubectl get pods -n clearledger
# All pods should be Running

argocd app get clearledger --grpc-web | grep -E "Sync Status|Health Status"
# Sync Status:   OutOfSync  ← expected — cluster is at rev 8, Git is still at the bad HEAD
# Health Status: Healthy    ← this is what matters right now
</code></pre>
<p><code>OutOfSync</code> is correct and expected at this point. The cluster is running the old good revision. Git still has the bad commit. You'll fix that next.</p>
<p><strong>Step 5: Fix Git (don't leave it broken)</strong></p>
<pre><code class="language-bash">cd ~/clearledger-infra
git pull
git revert &lt;bad-commit-sha&gt; --no-edit
git push
</code></pre>
<p><strong>Step 6: Re-enable auto-sync</strong></p>
<pre><code class="language-bash">argocd app set clearledger \
  --sync-policy automated \
  --self-heal \
  --auto-prune \
  --grpc-web

# Trigger an immediate sync so you do not wait for the next auto-check
argocd app sync clearledger --grpc-web

# Confirm everything is clean
argocd app get clearledger --grpc-web | grep -E "Sync Status|Health Status"
# Expected: Sync Status: Synced, Health Status: Healthy
</code></pre>
<p><strong>Never leave auto-sync disabled longer than the incident.</strong> It's your drift-detection and tamper-evidence mechanism: without it, unauthorized <code>kubectl</code> changes go undetected. Re-enable it the moment you push the Git fix.</p>
<h4 id="heading-practise-the-rollback-now-before-you-need-it-under-pressure">Practise the rollback now (before you need it under pressure)</h4>
<p>Don't wait for a real incident to run this for the first time. The steps below simulate a bad image tag deploy and walk you through Method 1 (the preferred path).</p>
<p><strong>Step 1: Push a bad image tag to</strong> <code>clearledger-infra</code></p>
<pre><code class="language-bash">cd ~/clearledger-infra
git pull

# Edit manifests/notification-service/deployment.yaml
# Change the image tag to a tag that does not exist, e.g.:
#   image: docker.io/$DOCKER_USERNAME/clearledger-notification-service:broken-tag

# Commit and push it
git add manifests/notification-service/deployment.yaml
git commit -m "test: simulate bad deploy with nonexistent image tag"
git push
</code></pre>
<p><strong>Step 2: Watch ArgoCD sync the bad state</strong></p>
<pre><code class="language-bash"># Give ArgoCD ~3 minutes to pick it up, or trigger immediately:
argocd app sync clearledger --grpc-web

# Watch the notification-service pod fail
kubectl get pods -n clearledger -w
# You will see: notification-service pod stuck in ImagePullBackOff or ErrImagePull
</code></pre>
<p><strong>Step 3: Roll back using Method 1</strong></p>
<pre><code class="language-bash">cd ~/clearledger-infra

# Revert the bad commit
git revert HEAD --no-edit
git push

# ArgoCD will auto-sync — or trigger it:
argocd app sync clearledger --grpc-web

# Watch pods recover
kubectl get pods -n clearledger -w
# notification-service should return to Running
</code></pre>
<p><strong>Step 4: Verify</strong></p>
<pre><code class="language-bash">argocd app get clearledger --grpc-web | grep -E "Sync Status|Health Status"
# Expected: Sync Status: Synced, Health Status: Healthy

kubectl get pods -n clearledger
# All pods Running, no ImagePullBackOff
</code></pre>
<p>You have now practised a rollback end-to-end. The <code>git revert</code> commit is permanently in the infra repo's history: a real audit record of a simulated recovery.</p>
<h4 id="heading-quick-reference">Quick reference</h4>
<p><strong>Use Method 1 (git revert) when:</strong></p>
<ul>
<li><p>A bad image tag or manifest was pushed to <code>clearledger-infra</code> and you have a few minutes</p>
</li>
<li><p>Any config change in the infra repo caused pods to break</p>
</li>
<li><p>This is almost always the right answer. It's fast, safe, and leaves a clean audit trail.</p>
</li>
</ul>
<p><strong>Use Method 2 (emergency ArgoCD rollback) when:</strong></p>
<ul>
<li><p>The cluster is broken right now, users are affected, and you need it stable in under 30 seconds</p>
</li>
<li><p>You're not yet sure which commit caused the problem and need time to investigate: roll back to stabilise, then use <code>git log</code> to find the culprit, then fix forward with Method 1</p>
</li>
</ul>
<p><strong>Neither method applies</strong> when a pod is crashing but nothing was pushed to the infra repo recently. This isn't a rollback problem. Check <code>kubectl logs</code>, Vault connectivity, and network policies instead.</p>
<p><code>revisionHistoryLimit: 10</code> in <code>stages/stage-2-gitops/argocd/clearledger-app.yaml</code> means ArgoCD always has 10 previous deployments available for emergency rollback. Increase it if your release cadence is high.</p>
<h3 id="heading-what-you-learned-in-stage-2">What You Learned in Stage 2</h3>
<ul>
<li><p>What GitOps means: Git is the single source of truth, and a tool enforces it</p>
</li>
<li><p>What ArgoCD does: watches Git, compares it to the cluster, corrects drift automatically</p>
</li>
<li><p>How the full flow works now: push code, CI builds image, CI updates infra repo, and ArgoCD syncs cluster.</p>
</li>
<li><p>No one runs <code>kubectl</code> to deploy anymore. The pipeline updates Git, ArgoCD does the rest.</p>
</li>
<li><p><strong>How to roll back safely:</strong> <code>git revert</code> in the infra repo is the correct answer, while ArgoCD emergency rollback is the break-glass option. You must disable auto-sync first or selfHeal will silently undo it.</p>
</li>
</ul>
<p><strong>What you can now put on your CV / say in an interview:</strong></p>
<blockquote>
<p>Implemented GitOps with ArgoCD so cluster state is driven from Git, with drift detection, auto-sync, and a Git-based rollback of a bad deploy.</p>
</blockquote>
<p><code>make snapshot STAGE=2 &amp;&amp; make snapshots</code>. Confirm <code>clearledger.stage2</code>. See <a href="#heading-how-to-save-your-progress">How to Save Your Progress</a>.</p>
<h2 id="heading-stage-3-security-gates">Stage 3 — Security Gates</h2>
<p>Every push runs security checks. Some failures stop the pipeline right away. Others you learn from now and enforce in the cluster later (Stage 4).</p>
<p><strong>Goal:</strong> understand six scanners: what each one looks at, what it catches, and how to read a failure. You'll break each gate on purpose (§3.4) so a failed CI job isn't a surprise.</p>
<p><strong>Ready for Stage 3?</strong></p>
<ul>
<li><p><code>make check-2</code> passes</p>
</li>
<li><p><code>ENABLE_ARGOCD_SYNC=true</code> on GitHub (you set this in Stage 2)</p>
</li>
<li><p><code>ENABLE_DAST</code> still <strong>unset</strong> (turn on later in this stage if you want)</p>
</li>
<li><p>Argo CD at <code>http://argocd.local</code> shows <strong>Synced</strong></p>
</li>
<li><p>Optional: skim <a href="#heading-stage-1-security-posture-what-blocks-vs-what-waits">Stage 1 security posture</a>. Stage 1 already ran many of these tools</p>
</li>
</ul>
<p><strong>Done when:</strong> <code>make check-3</code> passes and you triggered each gate once (§3.4). Then <code>make snapshot STAGE=3</code> and <code>make snapshots</code>.</p>
<h3 id="heading-what-you-need-to-know-first">What You Need to Know First</h3>
<p>One tool isn't enough. Each scanner guards a different layer:</p>
<ul>
<li><p><strong>Gitleaks</strong>: secrets in code or Git history (API keys, tokens)</p>
</li>
<li><p><strong>Semgrep (SAST)</strong>: bugs in your Python/JS source (injection, unsafe patterns)</p>
</li>
<li><p><strong>Trivy (SCA + images)</strong>: finds known security vulnerabilities (called CVEs) in your Python/Node.js packages and Docker images. A CVE (Common Vulnerabilities and Exposures) is a publicly tracked software security flaw with a unique identifier.</p>
</li>
<li><p><strong>Checkov (IaC)</strong>: misconfigurations in Dockerfiles, Kubernetes manifests, and Stage 8 Terraform.</p>
</li>
<li><p><strong>Cosign</strong>: proves images were built and signed by your pipeline</p>
</li>
</ul>
<p>What blocks CI today: secrets, bad code (SAST), vulnerable images, Dockerfile issues on production images.</p>
<p>What waits for later: Some Kubernetes issues are only reported in Stage 1. They show you what still needs hardening. In Stage 4, Kyverno turns the important rules into real cluster enforcement, so unsafe workloads are blocked before they run.</p>
<p>When you <code>git commit</code>, hooks on your laptop can scan first (pre-commit). When you <code>git push</code>, GitHub Actions scans again on the runner. Same idea twice: catch mistakes before they waste a 10-minute pipeline. Pre-commit is optional to install, as CI always runs on push either way.</p>
<h3 id="heading-enable-dast-optional-after-stage-2">Enable DAST (Optional After Stage 2)</h3>
<p>DAST (Dynamic Application Security Testing) scans the <strong>running</strong> app at <code>http://clearledger.local</code>. It was off in Stages 1–2 on purpose: Stage 1 never deployed to the cluster, and Stage 2 was about getting GitOps healthy first.</p>
<p>If <code>make check-2</code> passes and <code>curl http://clearledger.local/auth/health</code> returns <code>200</code>, you can turn DAST on:</p>
<p>Go to GitHub and into your <code>clearledger</code> repo. Then go to <strong>Settings, Secrets and variables, Actions, Variables</strong>, and <strong>New repository variable</strong>:</p>
<table>
<thead>
<tr>
<th>Name</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td><code>ENABLE_DAST</code></td>
<td><code>true</code></td>
</tr>
</tbody></table>
<p>Push a small commit (or re-run the last workflow on <code>main</code>). The <strong>DAST (OWASP ZAP + fintech API tests)</strong> job should run instead of <strong>skipped</strong>. A failed ZAP scan is a real finding to investigate. Skipped before this step only means the toggle was off.</p>
<h3 id="heading-31-install-pre-commit-hooks">3.1: Install Pre-commit Hooks</h3>
<pre><code class="language-bash"># macOS (Homebrew — avoids PEP 668 "externally-managed-environment" from pip3):
brew install pre-commit

# Linux/WSL2:
# sudo apt install -y pre-commit
# or: python3 -m pip install --user pre-commit

pre-commit install
pre-commit run --all-files
</code></pre>
<p>If a hook fails, read the error first. Gitleaks and Ruff should pass before you commit. Some YAML or Terraform hook issues may come from later-stage files. If that happens, continue with the stage instructions and use <a href="https://github.com/Osomudeya/clearledger/blob/main/docs/troubleshooting.md"><code>troubleshooting.md</code></a> for Gitleaks or CI scanner failures.</p>
<p>Test it catches secrets locally before CI does:</p>
<pre><code class="language-bash">echo 'AWS_SECRET = "'$(printf '%s%s' 'AKIA' 'IOSFODNN7EXAMPLE')'"' &gt;&gt; app/auth-service/main.py
git add app/auth-service/main.py &amp;&amp; git commit -m "test"

# Gitleaks fires and blocks the commit — see "What you should see" below

git restore --staged app/auth-service/main.py

git checkout app/auth-service/main.py
</code></pre>
<p>The commit was blocked before it even reached Git. If the pre-commit hook wasn't installed, that fake AWS key would be in your Git history permanently (even if you delete the line later, Git remembers).</p>
<p><strong>If you already did Cosign in Stage 1,</strong> that counts. Stage 3 doesn't require regenerating keys. Confirm that <code>infra/cosign.pub</code> exists and GitHub has <code>COSIGN_PRIVATE_KEY</code> + <code>COSIGN_PASSWORD</code>. Stage 4 turns signing into <strong>enforcement</strong> at the cluster gate.</p>
<p><strong>✋ Hands-on checkpoint: pre-commit actually blocks a secret</strong></p>
<p>Installed-but-not-wired is the classic silent failure. Prove the hooks fire:</p>
<pre><code class="language-bash">echo 'AWS_SECRET='"$(printf '%s%s' 'AKIA' 'IOSFODNN7EXAMPLE')" &gt; leak-test.env

git add leak-test.env

pre-commit run --all-files; echo "exit=$?"

git reset leak-test.env &gt;/dev/null; rm -f leak-test.env
</code></pre>
<p><strong>Expected:</strong> the secret-scanning hook <strong>fails</strong> the run (<code>exit=1</code>) and flags <code>leak-test.env</code>. If <code>exit=0</code>, your hooks are installed but not catching anything: re-run <code>pre-commit install</code> and confirm <code>.git/hooks/pre-commit</code> exists.</p>
<p>If you skip this, commits sail through unscanned and you'll believe Stage 3 is protecting you when it's not.</p>
<h3 id="heading-32-generate-cosign-keys">3.2: Generate Cosign Keys</h3>
<p>If you created Cosign keys in Stage 1 (§1.4), skip generation: go straight to inserting <code>cosign.pub</code> into the Kyverno policy and adding the GitHub secrets below.</p>
<p><strong>Cosign</strong> signs your Docker images with a cryptographic key. When you deploy to the cluster, Kyverno (Stage 4) can verify the signature and reject any image that wasn't signed by your pipeline. This prevents someone from pushing a malicious image to your Docker Hub and having the cluster run it.</p>
<pre><code class="language-bash"># macOS: brew install cosign

# Linux/WSL2: curl -O -L https://github.com/sigstore/cosign/releases/download/v2.2.4/cosign-linux-amd64 &amp;&amp; chmod +x cosign-linux-amd64 &amp;&amp; sudo mv cosign-linux-amd64 /usr/local/bin/cosign

cosign generate-key-pair   # enter a password when prompted
</code></pre>
<p>This creates two files: <code>cosign.key</code> (private, used by the pipeline to sign) and <code>cosign.pub</code> (public, used by Kyverno to verify).</p>
<p>Insert your public key into the Kyverno policy (replace the placeholder block in <code>infra/policies/require-signed-images.yaml</code> with the contents of <code>cosign.pub</code>).</p>
<p>Add secrets to GitHub (github.com/YOUR_USERNAME/clearledger → Settings → Secrets and variables → Actions):</p>
<table>
<thead>
<tr>
<th>Secret</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td><code>COSIGN_PRIVATE_KEY</code></td>
<td>Contents of <code>cosign.key</code></td>
</tr>
<tr>
<td><code>COSIGN_PASSWORD</code></td>
<td>The password you entered when generating keys</td>
</tr>
</tbody></table>
<p><strong>✋ Hands-on checkpoint: Cosign keys are ready</strong></p>
<p>Stage 4 uses <a href="http://cosign.pub"><code>cosign.pub</code></a> to verify signed images. Before you continue, confirm the key files exist and the private key isn't tracked by Git:</p>
<pre><code class="language-bash">test -f cosign.key &amp;&amp; echo "private key present"
test -f cosign.pub &amp;&amp; echo "public key present"
grep -q "BEGIN PUBLIC KEY" cosign.pub &amp;&amp; echo "public key valid"
git check-ignore cosign.key &amp;&amp; echo "private key correctly ignored"
</code></pre>
<p><strong>Expected:</strong> all four lines should print.</p>
<p>If <code>git check-ignore cosign.key</code> prints nothing, add <code>cosign.key</code> to <code>.gitignore</code> before committing anything. The private key must stay out of Git.</p>
<p>Don't skip this check. Stage 4 needs the public key for the Kyverno image-signing policy, and the private key must remain local.</p>
<h3 id="heading-33-activate-the-full-security-pipeline">3.3: Activate the Full Security Pipeline</h3>
<p>The security gates are already in <code>.github/workflows/ci.yaml</code>. Push any change to trigger the full pipeline:</p>
<pre><code class="language-bash">git add . &amp;&amp; git commit -m "ci: full DevSecOps pipeline" &amp;&amp; git push origin main
</code></pre>
<h3 id="heading-34-break-each-gate-on-purpose">3.4: Break Each Gate on Purpose</h3>
<p>For each gate, you'll want to break something on purpose, read how the tool reports it, revert, and confirm green again. Try the local command first, then push once if you want a screenshot on GitHub Actions.</p>
<pre><code class="language-bash"># 1. Break it   2. Run locally or push   3. Read the failure
# 4. git checkout -- path/to/file   5. pre-commit run --all-files (optional)   6. git push
</code></pre>
<p>Start with <strong>Gate 1</strong> end-to-end before the others.</p>
<h4 id="heading-gate-1-gitleaks-secrets">Gate 1: Gitleaks (secrets)</h4>
<p><strong>Inject:</strong> hardcoded AWS key in any Python file.</p>
<p>The goal is to prove the secret scanner works.</p>
<p>This command adds a fake AWS-looking key to <code>app/auth-service/main.py</code>:</p>
<pre><code class="language-bash">echo 'AWS_KEY = "'$(printf '%s%s' 'AKIA' 'IOSFODNN7EXAMPLE')'"' &gt;&gt; app/auth-service/main.py

git add app/auth-service/main.py &amp;&amp; git commit -m "test: trigger gitleaks"
# pre-commit blocks this commit locally — that is the test.
# For a CI screenshot only: git commit --no-verify -m "test: trigger gitleaks" &amp;&amp; git push
</code></pre>
<p><strong>Done looks like this (terminal: pre-commit):</strong></p>
<pre><code class="language-text">🔑 Secrets scan (Gitleaks)...............................................Failed
- hook id: gitleaks
- exit code: 1

Finding:     AWS_KEY = "REDACTED"
RuleID:      aws-access-token
File:        app/auth-service/main.py
Line:        316
</code></pre>
<p><strong>Expected:</strong> the commit should fail. Gitleaks should report one secret finding in <code>app/auth-service/</code><a href="http://main.py"><code>main.py</code></a>.</p>
<p>That failure is good. It means the local pre-commit hook caught the secret before it reached Git.</p>
<p><strong>Revert:</strong></p>
<pre><code class="language-bash">git restore --staged app/auth-service/main.py 2&gt;/dev/null
git checkout app/auth-service/main.py
pre-commit run gitleaks --all-files   # → Passed
</code></pre>
<h4 id="heading-gate-2-semgrep-sast">Gate 2: Semgrep (SAST)</h4>
<p><strong>Local dry-run</strong> (no repo change):</p>
<pre><code class="language-bash">python3 -m venv /tmp/sec-gates-venv &amp;&amp; /tmp/sec-gates-venv/bin/pip install semgrep
cat &gt; /tmp/semgrep-bad.py &lt;&lt; 'EOF'
import subprocess
from fastapi import Request
def bad(request: Request):
    subprocess.run(request.query_params.get("cmd"), shell=True)
EOF
/tmp/sec-gates-venv/bin/semgrep \
  --config=p/python --config=p/security-audit --config=p/owasp-top-ten --error \
  /tmp/semgrep-bad.py
</code></pre>
<p><strong>Break CI</strong>: add a temporary file Semgrep will scan, commit, and push:</p>
<pre><code class="language-bash">cat &gt; app/auth-service/gate_test_semgrep.py &lt;&lt; 'EOF'
import subprocess
from fastapi import Request
def bad(request: Request):
    subprocess.run(request.query_params.get("cmd"), shell=True)
EOF

git add app/auth-service/gate_test_semgrep.py &amp;&amp; git commit -m "test: trigger semgrep" &amp;&amp; git push
</code></pre>
<p><strong>Expected result:</strong> Semgrep reports <code>subprocess-shell-true</code> as <code>Blocking</code>. The <code>SAST (Semgrep)</code> job turns red, and the image build jobs don't run.</p>
<p><strong>Revert:</strong></p>
<pre><code class="language-bash">rm -f app/auth-service/gate_test_semgrep.py
git add -A &amp;&amp; git commit -m "revert: semgrep gate test" &amp;&amp; git push
</code></pre>
<h4 id="heading-gate-3-checkov-iac-dockerfile">Gate 3: Checkov (IaC / Dockerfile)</h4>
<p>Checkov scans Dockerfiles and Kubernetes manifests for unsafe configuration.</p>
<p>First, run a local demo. This removes the <code>HEALTHCHECK</code> from a copied Dockerfile and shows how Checkov reports it:</p>
<pre><code class="language-bash">python3 -m venv /tmp/sec-gates-venv &amp;&amp; /tmp/sec-gates-venv/bin/pip install checkov
sed '/^HEALTHCHECK/,+1d' app/auth-service/Dockerfile &gt; /tmp/Dockerfile-nohc
mkdir -p /tmp/checkov-demo/app/auth-service
cp /tmp/Dockerfile-nohc /tmp/checkov-demo/app/auth-service/Dockerfile
/tmp/sec-gates-venv/bin/checkov --directory /tmp/checkov-demo --framework dockerfile
</code></pre>
<p>Now trigger a Checkov finding in CI by exposing SSH port <code>22</code> in the auth-service Dockerfile:</p>
<pre><code class="language-bash">echo 'EXPOSE 22' &gt;&gt; app/auth-service/Dockerfile
git add app/auth-service/Dockerfile &amp;&amp; git commit -m "test: trigger checkov" &amp;&amp; git push
</code></pre>
<p><strong>Expected result:</strong> the Checkov log or artifact should show <code>CKV_DOCKER_1</code>, which means an SSH port was exposed.</p>
<p>The <code>IaC Scan (Checkov)</code> job may or may not turn red, depending on the severity Checkov assigns. That's okay for this exercise. The goal is to find and understand the Checkov result.</p>
<p>If you need a screenshot of a failed GitHub Actions job, use Gate 1, Gate 2, or Gate 4. Those are designed to turn the workflow red. Checkov is mainly for reading the finding, so it may stay green.</p>
<p><strong>Revert:</strong></p>
<pre><code class="language-bash">git checkout app/auth-service/Dockerfile
git commit -am "revert: checkov gate test" &amp;&amp; git push
</code></pre>
<h4 id="heading-gate-4-trivy-image-cves">Gate 4: Trivy (image CVEs)</h4>
<p><strong>Local dry-run</strong>: scan an old base image (no build):</p>
<pre><code class="language-bash">trivy image --exit-code 1 --severity CRITICAL,HIGH --ignore-unfixed python:3.8-slim
</code></pre>
<p><strong>Break CI</strong>: pin an old base in the Dockerfile, push, wait for <code>Scan images</code>:</p>
<pre><code class="language-bash">sed -i.bak 's/FROM python:3.13-slim/FROM python:3.8-slim/' app/auth-service/Dockerfile
git add app/auth-service/Dockerfile &amp;&amp; git commit -m "test: trigger trivy" &amp;&amp; git push
</code></pre>
<p><strong>Pass:</strong> <code>Scan images</code> → <strong>Trivy scan all images</strong> exits 1 with a CVE table (<code>HIGH</code> / <code>CRITICAL</code>). <code>Publish images</code> and <code>Update Manifests</code> are skipped.</p>
<p><strong>Revert:</strong></p>
<pre><code class="language-bash">git checkout app/auth-service/Dockerfile
git commit -am "revert: trivy gate test" &amp;&amp; git push
</code></pre>
<h3 id="heading-35-when-a-scan-fails-on-a-cve-you-didnt-inject">3.5: When a Scan Fails on a CVE You Didn't Inject</h3>
<p>§3.4 is deliberate. This section is for the other case where you push normal code, but the image scan fails because a new vulnerability was found.<br>That's normal. CVE databases update all the time. Don't weaken the scan. Fix the vulnerable package or image.</p>
<p>First, find the real CVE. In GitHub Actions, open <strong>Scan images</strong> then go to <strong>Trivy scan all images</strong> and look for the table with:</p>
<ul>
<li><p>Package</p>
</li>
<li><p>CVE</p>
</li>
<li><p>Installed version</p>
</li>
<li><p>Fixed version You can also download the artifact:</p>
</li>
</ul>
<p><strong>Ignore this red herring</strong> at the bottom of the log:</p>
<pre><code class="language-text">Version 0.71.2 of Trivy is now available
Error: Process completed with exit code 1.
</code></pre>
<p>The version notice doesn't fail the job. A fixable HIGH/CRITICAL CVE does. Don't add <code>--skip-version-check</code> to “fix” it.</p>
<p><strong>Instead, fix it with:</strong></p>
<ul>
<li><p><strong>pip package</strong>: bump to the Fixed Version in <code>requirements.txt</code> (example: <code>python-multipart==0.0.30</code> for CVE-2026-53539). Apply the same bump to sibling services if they share that pin.</p>
</li>
<li><p><strong>OS package</strong>: newer base image or a targeted <code>apt</code>/<code>apk</code> upgrade in the Dockerfile.</p>
</li>
<li><p><strong>No stable fix yet</strong> documented exception only: add the CVE to <code>.trivyignore</code> and <code>.grype.yaml</code> with a comment (see <code>CVE-2026-7210</code>).</p>
</li>
</ul>
<p>Don't remove <code>--exit-code 1</code>, lower the severity rule, or disable scanning. For help, see <a href="troubleshooting.md#trivy-version-x-is-now-available-notice-not-a-scan-failure">Trivy version notice</a> and <a href="https://github.com/Osomudeya/clearledger/blob/main/docs/troubleshooting.md">Trivy blocks Python service images</a>.</p>
<h3 id="heading-finish-stage-3">Finish Stage 3</h3>
<p>For screenshots, use one clear failed gate:</p>
<ul>
<li><p>Gitleaks: <code>Secrets Scan</code></p>
</li>
<li><p>Semgrep: <code>SAST</code></p>
</li>
<li><p>Trivy: <code>Scan images</code></p>
</li>
<li><p>Checkov: look for <code>CKV_*</code> in the log or artifact. The job may stay green</p>
</li>
</ul>
<p>After each test in §3.4, undo the test change, push the revert, and confirm the workflow is green again. One red GitHub Actions screenshot is enough for your portfolio.</p>
<p><strong>Run the stage check:</strong></p>
<pre><code class="language-bash">make check-3   # must end: All checks passed. Ready for the next stage.
</code></pre>
<p><strong>Expected:</strong> <code>All checks passed. Ready for the next stage.</code></p>
<p>You should also have triggered at least one gate in §3.4. A local Gitleaks failure counts.</p>
<p><code>ENABLE_DAST=true</code> is optional. You only need it if you want to run ZAP later.</p>
<p><strong>Not required yet:</strong> Checkov blocking Kubernetes manifests or Cosign blocking deployments. Stage 4 turns those into cluster enforcement with Kyverno.</p>
<p>Next, save your progress:</p>
<pre><code class="language-bash">make snapshot STAGE=3 &amp;&amp; make snapshots
</code></pre>
<h2 id="heading-stage-4-admission-control-kyverno">Stage 4 — Admission Control (Kyverno)</h2>
<p>Even if CI passes, the cluster can still refuse.</p>
<p>CI scans your code and images before they reach GitOps, but it can't watch everything that happens inside the cluster. Someone with <code>kubectl</code> access could apply a manifest directly.</p>
<p>A Helm chart you install might create pods that violate your security standards. Those paths never hit the pipeline, which is why Stage 4 adds admission control: a checkpoint built into Kubernetes itself.</p>
<p>Every time something tries to create or update a resource, the request passes through admission webhooks before it takes effect. If a webhook rejects the request, the resource is never created.</p>
<p><strong>Kyverno</strong> is a Kubernetes-native policy engine that uses those webhooks. You write policies as YAML files (not application code), and Kyverno enforces them on every matching resource in the cluster, for example, rejecting any pod that runs as root or requiring CPU and memory limits on every container.</p>
<p>The difference from CI is timing: CI scans <em>before</em> code ships, while Kyverno enforces at the <em>cluster gate</em>. Together they give you two layers of defense.</p>
<p>Your goal in this stage is to install Kyverno, apply the policies in <code>infra/policies/</code>, and prove in §4.4 that non-compliant pods are denied before the container runtime ever sees them.</p>
<p>Before you start, make sure the foundation from earlier stages is still solid: <code>make check-3</code> should pass (pre-commit hooks and CI security gates are active), <code>infra/cosign.pub</code> should exist from Stage 3, and ArgoCD should still be syncing so the app responds at <code>http://clearledger.local</code>. If any of those are red, fix them first: Kyverno sits on top of a healthy cluster, not a broken one.</p>
<p>You're done with Stage 4 when all three break-it scenarios in §4.4 are denied and <code>make check-4</code> passes.</p>
<p><strong>What changes from Stage 3 is enforcement, not scanning.</strong> In CI, Checkov reported Kubernetes misconfigurations but didn't block the pipeline. Kyverno now stops those same classes of problems at the cluster gate.</p>
<p>Cosign has been signing your images since Stage 1. Kyverno now <em>requires</em> that signature before a ClearLedger image can deploy. This is where <a href="#heading-stage-1-security-posture-what-blocks-vs-what-waits">Stage 1 evidence becomes enforcement</a>. See that section if you want the full map of what blocked in Stage 1 versus what waited for Stage 4.</p>
<p>Start with §4.1 to install Kyverno. If the install, policies, break-it scenarios, or <code>make check-4</code> fail, read <a href="https://github.com/Osomudeya/clearledger/blob/main/docs/troubleshooting.md"><code>troubleshooting.md</code></a> and <strong>Stage 4: Admission Control (Kyverno)</strong> before changing Helm values or policy YAML.</p>
<h3 id="heading-what-kyverno-enforces">What Kyverno Enforces</h3>
<p>All policy files live in <code>infra/policies/</code>. Kyverno itself is installed via Helm using <code>stages/stage-4-admission-control/infra/kyverno/values.yaml</code>.</p>
<table>
<thead>
<tr>
<th>Policy</th>
<th>What it enforces</th>
<th>Framework</th>
</tr>
</thead>
<tbody><tr>
<td><code>disallow-root-containers</code></td>
<td><code>runAsNonRoot: true</code></td>
<td>CIS K8s 5.2.6</td>
</tr>
<tr>
<td><code>require-resource-limits</code></td>
<td>CPU/memory requests and limits</td>
<td>CIS K8s 5.2.4</td>
</tr>
<tr>
<td><code>disallow-privilege-escalation</code></td>
<td><code>allowPrivilegeEscalation: false</code></td>
<td>CIS K8s 5.2.5</td>
</tr>
<tr>
<td><code>drop-all-capabilities</code></td>
<td><code>capabilities.drop: [ALL]</code></td>
<td>CIS K8s 5.2.7</td>
</tr>
<tr>
<td><code>require-signed-images</code></td>
<td>Cosign signature on ClearLedger images</td>
<td>SLSA Level 2</td>
</tr>
</tbody></table>
<h3 id="heading-platform-stability-from-stage-4-onward">Platform Stability: From Stage 4 Onward</h3>
<p>From Stage 4 on, you're running more controllers on a single-node VM. Kyverno, storage provisioners, and later Prometheus and Loki. A pod can show <code>Running</code> while it's actually crash-looping in the background.</p>
<p>When platform pods (Kyverno controllers, <code>hostpath-provisioner</code>, the Prometheus operator, and similar) accumulate high <code>RESTARTS</code>, the API server starts timing out, <code>kubectl</code> feels flaky, and you can waste days debugging the wrong component because the app pods look fine.</p>
<p>After every stage from here on, give the cluster about ten minutes to settle, then run the stage health check:</p>
<pre><code class="language-bash">bash scripts/health-check.sh &lt;stage&gt;    # for example, 4, 7, 7.5
# or the Makefile shortcut:
make check-4
</code></pre>
<p>The script ends with a Platform stability section that flags pods with suspicious restart counts. You can also scan the worst offenders yourself. This lists the fifteen pods with the highest restart counts cluster-wide, which is useful when something feels slow but you're not sure which namespace is struggling:</p>
<pre><code class="language-bash">kubectl get pods -A --sort-by='.status.containerStatuses[0].restartCount' \
  -o custom-columns='NS:.metadata.namespace,NAME:.metadata.name,RESTARTS:.status.containerStatuses[0].restartCount' \
  | tail -15
</code></pre>
<p><strong>The gate:</strong> Kyverno controllers and other platform pods should show <strong>RESTARTS under 5</strong> after the stage settles. If any platform pod is climbing past 10, stop and fix it with the documented Helm values or <a href="https://github.com/Osomudeya/clearledger/blob/main/docs/troubleshooting.md">troubleshooting.md</a>. Don't <code>kubectl patch</code> around it and move on. A stable platform layer is a prerequisite for every stage that follows.</p>
<h3 id="heading-41-install-kyverno">4.1: Install Kyverno</h3>
<pre><code class="language-bash">helm repo add kyverno https://kyverno.github.io/kyverno/
helm repo update

helm upgrade --install kyverno kyverno/kyverno \
  --version 3.2.8 \
  --namespace kyverno \
  --create-namespace \
  -f stages/stage-4-admission-control/infra/kyverno/values.yaml \
  --wait --timeout=600s
</code></pre>
<p>The values file does three important things for the lab:</p>
<ol>
<li><p><strong>Disables cleanup CronJobs</strong>: older Kyverno charts pull <code>bitnami/kubectl</code>, which was removed from Docker Hub and causes <code>ImagePullBackOff</code> on cleanup pods.</p>
</li>
<li><p><strong>Points Helm hooks at</strong> <code>bitnamilegacy/kubectl</code>, so future <code>helm uninstall</code> doesn't hang on a missing image.</p>
</li>
<li><p><strong>Extends liveness probe timeouts</strong>: the default <code>timeoutSeconds: 5, failureThreshold: 2</code> is too tight for a loaded single-node VM. Under CPU pressure, the health endpoint can take &gt;5s to respond, which triggers a restart cascade that saturates the node and makes the API server intermittently unreachable. The values file sets <code>timeoutSeconds: 30, failureThreshold: 5</code> so Kyverno survives load spikes without crash-looping.</p>
</li>
</ol>
<p><strong>What you should see:</strong></p>
<pre><code class="language-yaml">Release "kyverno" does not exist. Installing it now.
NAME: kyverno
NAMESPACE: kyverno
STATUS: deployed
...
Kyverno version: v1.12.6
</code></pre>
<p>Verify all four controllers are running (first pull can take several minutes on a slow connection):</p>
<pre><code class="language-markdown">kubectl get pods -n kyverno
</code></pre>
<pre><code class="language-plaintext">NAME                                             READY   STATUS    RESTARTS   AGE
kyverno-admission-controller-bd685cd4b-f6kl6     1/1     Running   0          2m
kyverno-background-controller-66fcfc6d87-59wgt   1/1     Running   0          2m
kyverno-cleanup-controller-5c5bf8bc6b-7kspq      1/1     Running   0          2m
kyverno-reports-controller-5cdd6f4c48-qf5wc      1/1     Running   0          2m
</code></pre>
<p>If pods stay in <code>ContainerCreating</code> for a long time, the node is still pulling images from <code>ghcr.io/kyverno</code>. Wait. Don't start a second Helm install on top of a partial one.</p>
<h4 id="heading-stability-gate-kyverno-install-only-before-42">Stability gate: Kyverno install only (before §4.2):</h4>
<p>Before continuing, make sure the Kyverno pods are healthy:</p>
<pre><code class="language-bash">kubectl get pods -n kyverno
</code></pre>
<p><strong>Expected:</strong> the Kyverno controller pods show <code>1/1 Running</code>, with low restart counts such as <code>0</code>, <code>1</code>, or <code>2</code>, and the restart count isn't increasing.</p>
<p>Don't run <code>make check-4</code> yet. That check also looks for the policies you apply later in §4.3, so it may fail at this point even if Kyverno installed correctly.</p>
<h3 id="heading-42-confirm-your-cosign-public-key-is-in-the-policy">4.2: Confirm your Cosign Public Key is in the Policy</h3>
<p>Stage 3 created <code>infra/cosign.pub</code>. Kyverno uses that same key to verify image signatures when a pod is created. The policy file ships with a placeholder. You must replace it with your key before applying policies in §4.3.</p>
<h4 id="heading-step-1-show-your-key-run-from-the-repo-root-on-the-vm">Step 1: Show your key (run from the repo root on the VM)</h4>
<pre><code class="language-bash">cd ~/clearledger    # or wherever you cloned the repo
cat infra/cosign.pub
</code></pre>
<p>You should see three lines: <code>-----BEGIN PUBLIC KEY-----</code>, a long base64 line, and <code>-----END PUBLIC KEY-----</code>. Copy that whole block (you'll paste it in the next step).</p>
<h4 id="heading-step-2-paste-the-key-into-the-policy">Step 2: Paste the key into the policy</h4>
<p>Open <code>infra/policies/require-signed-images.yaml</code> in your editor (<code>nano</code>, <code>vim</code>, or VS Code).</p>
<p>Find this line:</p>
<pre><code class="language-yaml">                      PASTE_YOUR_COSIGN_PUBLIC_KEY_HERE
</code></pre>
<p>Delete <strong>only</strong> that placeholder line and paste the three lines from <code>cosign.pub</code> in its place. The result should look like this (your base64 line will differ):</p>
<pre><code class="language-yaml">                - keys:
                    publicKeys: |-
                      -----BEGIN PUBLIC KEY-----
                     JFkwEwYHKoZIzj0CAQYIKoFIzj0DAQcDQgZEI...
                      -----END PUBLIC KEY-----
</code></pre>
<p>Save the file. Keep the pasted key indented under <code>publicKeys: |-</code>. The <code>BEGIN PUBLIC KEY</code> and <code>END PUBLIC KEY</code> lines should have spaces before them, just like the base64 line between them.</p>
<h4 id="heading-step-3-verify-three-quick-checks">Step 3: Verify (three quick checks)</h4>
<p>Run these one at a time from the repo root:</p>
<pre><code class="language-bash"># Check A — placeholder must be gone
grep PASTE_YOUR_COSIGN_PUBLIC_KEY_HERE infra/policies/require-signed-images.yaml \
  &amp;&amp; echo "❌ FAIL: placeholder still in file — edit and save again" \
  || echo "✓ OK: placeholder removed"
</code></pre>
<pre><code class="language-bash"># Check B — key block must be present exactly once
grep -c "BEGIN PUBLIC KEY" infra/policies/require-signed-images.yaml
</code></pre>
<p>Expected output for Check B: <code>1</code> (if you see <code>0</code>, the key was not pasted. If <code>2</code>, you pasted it twice).</p>
<pre><code class="language-bash"># Check C — policy key must match cosign.pub byte-for-byte
diff infra/cosign.pub \
  &lt;(sed -n '/-----BEGIN PUBLIC KEY-----/,/-----END PUBLIC KEY-----/p' \
      infra/policies/require-signed-images.yaml | sed 's/^[[:space:]]*//')
</code></pre>
<p>Expected output for Check C: <strong>nothing</strong>. No diff lines means the keys match. If <code>diff</code> prints differences, open the policy file and fix the paste.</p>
<p>If all three passed, continue to §4.3.</p>
<p><strong>If you skip this</strong>, Scenario 3 in §4.4 fails in a confusing way: unsigned images may slip through, or signed pods may be rejected because Kyverno is checking against the wrong key.</p>
<h3 id="heading-43-apply-the-five-core-policies">4.3: Apply the Five Core Policies</h3>
<p>Now you'll apply the five policies that map to CIS controls. Don't apply <code>verify-slsa-provenance.yaml</code> yet. It's an optional SLSA attestation policy (Audit mode) for a later enhancement.</p>
<p>Stage 4 applies <code>infra/policies/require-signed-images.yaml</code>. This policy uses <code>failurePolicy: Fail</code>, so if Kyverno can't verify an image signature, the pod is blocked instead of allowed. The ECR policy with <code>failurePolicy: Ignore</code> is for Stage 8, not this step.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/67a638f5-65b8-41d2-be68-babe6c7b8c99.png" alt="screenshot image showing infra policy Yaml file failurePolicy as &quot;Fail&quot;" style="display: block;" width="721" height="193" loading="lazy">

<pre><code class="language-bash">kubectl apply \
  -f infra/policies/disallow-root.yaml \
  -f infra/policies/disallow-privilege-escalation.yaml \
  -f infra/policies/drop-all-capabilities.yaml \
  -f infra/policies/require-resource-limits.yaml \
  -f infra/policies/require-signed-images.yaml
</code></pre>
<p>Wait a few seconds, then confirm all policies show <code>READY: True</code> and <code>VALIDATE ACTION: Enforce</code>:</p>
<pre><code class="language-bash">kubectl get clusterpolicy
</code></pre>
<pre><code class="language-plaintext">NAME                            ADMISSION   BACKGROUND   VALIDATE ACTION   READY   AGE
disallow-privilege-escalation   true        true         Enforce           True    10s
disallow-root-containers        true        true         Enforce           True    10s
drop-all-capabilities           true        true         Enforce           True    10s
require-resource-limits         true        true         Enforce           True    10s
require-signed-images           true        false        Enforce           True    10s
</code></pre>
<p>If <code>READY</code> stays empty, check Kyverno logs: <code>kubectl logs -n kyverno -l app.kubernetes.io/component=admission-controller --tail=50</code>.</p>
<h3 id="heading-44-breaking-it-on-purpose">4.4: Breaking it on Purpose</h3>
<p>Now you'll test the policies by trying to create bad pods.</p>
<p>These pods are supposed to fail. That's the point.</p>
<p>CI tools like Checkov warn you in a report. Kyverno goes further: it blocks unsafe pods before Kubernetes runs them.</p>
<p>For each test, read the error message, as it should tell you which policy blocked the pod and what field was wrong. That error message is your proof that admission control is working.</p>
<table>
<thead>
<tr>
<th>Scenario</th>
<th>What you simulate</th>
<th>Policy under test</th>
<th>Success looks like</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>Attacker applies a bare pod (no hardening)</td>
<td>Root, caps, privilege, limits</td>
<td>Four policies fire, pod <code>NotFound</code></td>
</tr>
<tr>
<td>2</td>
<td>Developer fixes securityContext but forgets limits</td>
<td>Resource limits only</td>
<td>One policy fires, pod <code>NotFound</code></td>
</tr>
<tr>
<td>3</td>
<td>Attacker pushes unsigned image to Docker Hub</td>
<td>Cosign signature</td>
<td><code>require-signed-images</code> denies, pod <code>NotFound</code></td>
</tr>
</tbody></table>
<h4 id="heading-scenario-1-root-container-no-securitycontext">Scenario 1: root container (no securityContext)</h4>
<p><strong>What you're simulating:</strong> Someone with <code>kubectl</code> access bypasses CI and applies a minimal pod: no <code>securityContext</code>, no resource limits.</p>
<p>This is exactly what Stage 1 Checkov flagged as evidence. Stage 4 now blocks it.</p>
<p><strong>What's wrong with this manifest:</strong> The container has only a name and image. It will run as root by default, keep all Linux capabilities, and has no CPU/memory bounds.</p>
<pre><code class="language-bash">cat &lt;&lt;EOF | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
  name: root-test
  namespace: clearledger
spec:
  containers:
    - name: test
      image: nginx:alpine
EOF
</code></pre>
<p><strong>What you should see:</strong></p>
<pre><code class="language-yaml">Error from server: error when creating "STDIN": admission webhook "validate.kyverno.svc-fail" denied the request:

resource Pod/clearledger/root-test was blocked due to the following policies

disallow-privilege-escalation:
  check-allowPrivilegeEscalation: 'validation error: allowPrivilegeEscalation must
    be set to false. rule check-allowPrivilegeEscalation failed at path /spec/containers/0/securityContext/'
disallow-root-containers:
  check-runAsNonRoot: |-
    validation error: Root containers are blocked in the clearledger namespace. Set securityContext.runAsNonRoot: true on the pod or container.
    . rule check-runAsNonRoot failed at path /spec/containers/0/securityContext/
drop-all-capabilities:
  check-capabilities: 'validation error: All containers must drop ALL capabilities.
    rule check-capabilities failed at path /spec/containers/0/securityContext/'
require-resource-limits:
  check-resources: 'validation error: Resource requests and limits are required for
    all containers. rule check-resources failed at path /spec/containers/0/resources/limits/'
</code></pre>
<p><strong>How to read this output:</strong></p>
<p>The important line is:</p>
<pre><code class="language-text">resource Pod/clearledger/root-test was blocked due to the following policies
</code></pre>
<p>That means Kyverno stopped the pod before it was created.</p>
<p>Under that line, Kyverno lists every policy the pod failed. For example:</p>
<pre><code class="language-text">disallow-root-containers:
  check-runAsNonRoot:
</code></pre>
<p>This means the pod failed the <code>disallow-root-containers</code> policy, specifically the <code>check-runAsNonRoot</code> rule. The fix is also shown in the message:</p>
<pre><code class="language-text">Set securityContext.runAsNonRoot: true
</code></pre>
<p>The same pattern applies to the other policies:</p>
<ul>
<li><p><code>disallow-privilege-escalation</code> means the pod didn't set <code>allowPrivilegeEscalation: false</code></p>
</li>
<li><p><code>drop-all-capabilities</code> means the pod didn't drop Linux capabilities with <code>capabilities.drop: [ALL]</code></p>
</li>
<li><p><code>require-resource-limits</code> means the pod didn't set CPU and memory requests/limits</p>
</li>
</ul>
<p>The <code>path</code> part tells you where Kubernetes expected the missing setting. For example, <code>/spec/containers/0/securityContext/</code> means: look inside the pod spec, then the first container, then its <code>securityContext</code>.</p>
<p>And <code>/spec/containers/0/resources/limits/</code> means: look inside the first container's resource limits.</p>
<p>So this one bad pod failed four controls at once. That's the lesson: Kyverno doesn't just say "no." It tells you which policy failed and where to fix the YAML.</p>
<p><strong>Verify enforcement worked:</strong></p>
<pre><code class="language-bash">kubectl get pod root-test -n clearledger
# Error from server (NotFound): pods "root-test" not found
</code></pre>
<p>If you see a pod in <code>Running</code> or <code>Pending</code>, policies aren't enforcing: re-check that <code>kubectl get clusterpolicy</code> shows all five <code>READY: True</code>.</p>
<p><strong>Take a screenshot.</strong> This is portfolio evidence for CIS Kubernetes Benchmark 5.2.6: enforced, not just configured.</p>
<h4 id="heading-scenario-2-missing-resource-limits">Scenario 2: missing resource limits</h4>
<p><strong>What you're simulating:</strong> A developer who read the securityContext requirements and fixed root/caps/privilege. But skipped resource limits.</p>
<p>This is common in real teams: “we hardened the container” but forgot CPU/memory bounds.</p>
<p><strong>What's wrong with this manifest:</strong> <code>securityContext</code> is correct, but there's no <code>resources.requests</code> or <code>resources.limits</code>. A container without limits can starve other workloads on the node.</p>
<pre><code class="language-bash">cat &lt;&lt;EOF | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
  name: nolimits-test
  namespace: clearledger
spec:
  containers:
    - name: test
      image: nginx:alpine
      securityContext:
        runAsNonRoot: true
        runAsUser: 1000
        allowPrivilegeEscalation: false
        capabilities:
          drop: [ALL]
EOF
</code></pre>
<p><strong>What you should see:</strong></p>
<pre><code class="language-plaintext">Error from server: error when creating "STDIN": admission webhook "validate.kyverno.svc-fail" denied the request:

resource Pod/clearledger/nolimits-test was blocked due to the following policies

require-resource-limits:
  check-resources: 'validation error: Resource requests and limits are required for
    all containers. rule check-resources failed at path /spec/containers/0/resources/limits/'
</code></pre>
<p><strong>Key observation:</strong> Only one policy fires this time: the securityContext fields satisfied the other four rules. Kyverno evaluates rules independently. Each container property is a separate gate.</p>
<p><strong>Verify:</strong></p>
<pre><code class="language-bash">kubectl get pod nolimits-test -n clearledger
# Error from server (NotFound): pods "nolimits-test" not found
</code></pre>
<h4 id="heading-scenario-3-unsigned-clearledger-image">Scenario 3: unsigned ClearLedger image</h4>
<p><strong>What you're simulating:</strong> A supply-chain attack: someone pushes a malicious image to Docker Hub under your repo name (<code>clearledger-auth-service</code>) without going through your signed CI pipeline. Stage 3 made Cosign signing possible, while Stage 4 makes it mandatory at the cluster gate.</p>
<p><strong>Why this setup is needed:</strong> Kyverno checks image signatures against the image in Docker Hub, not against images on your laptop. The test image tag must exist in Docker Hub first.</p>
<p>If you use a fake tag like <code>:unsigned</code> that was never pushed, Kubernetes may fail later with <code>ImagePullBackOff</code>. That only means the image can't be pulled; it doesn't prove Kyverno blocked an unsigned image.</p>
<h4 id="heading-step-1-push-a-deliberately-unsigned-test-image-one-time">Step 1: push a deliberately unsigned test image (one-time):</h4>
<pre><code class="language-bash">export DOCKER_USERNAME=your-dockerhub-username

docker pull nginx:alpine
docker tag nginx:alpine ${DOCKER_USERNAME}/clearledger-auth-service:unsigned-test
docker push ${DOCKER_USERNAME}/clearledger-auth-service:unsigned-test

# Must fail — proves the image has no Cosign signature from your pipeline key:
cosign verify --key infra/cosign.pub \
  index.docker.io/${DOCKER_USERNAME}/clearledger-auth-service:unsigned-test
# Error: no signatures found
</code></pre>
<h4 id="heading-step-2-try-to-deploy-it-with-a-compliant-pod-spec">Step 2: try to deploy it with a compliant pod spec:</h4>
<p>The pod manifest is fully hardened (securityContext + limits) so only the signature policy can fail. Use <code>index.docker.io/</code> in the image URL: on Kyverno 1.12, <code>docker.io/...</code> may not trigger <code>verifyImages</code> matching.</p>
<pre><code class="language-bash">cat &lt;&lt;EOF | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
  name: unsigned-test
  namespace: clearledger
spec:
  containers:
    - name: test
      image: index.docker.io/${DOCKER_USERNAME}/clearledger-auth-service:unsigned-test
      securityContext:
        runAsNonRoot: true
        runAsUser: 1000
        allowPrivilegeEscalation: false
        capabilities:
          drop: [ALL]
      resources:
        requests:
          memory: "64Mi"
          cpu: "50m"
        limits:
          memory: "128Mi"
          cpu: "200m"
EOF
</code></pre>
<p><strong>What you should see:</strong></p>
<pre><code class="language-plaintext">Error from server: error when creating "STDIN": admission webhook "mutate.kyverno.svc-fail" denied the request:

resource Pod/clearledger/unsigned-test was blocked due to the following policies

require-signed-images:
  verify-cosign-signature: 'failed to verify image index.docker.io/veeno-demo/clearledger-auth-service:unsigned-test:
    .attestors[0].entries[0].keys: no signatures found'
</code></pre>
<p><strong>How to read this output:</strong></p>
<ul>
<li><p>Note the webhook name is <code>mutate.kyverno.svc-fail</code>, not <code>validate</code>: image verification runs in Kyverno’s mutate pass (digest + signature check) before the pod is admitted.</p>
</li>
<li><p><code>no signatures found</code> means Kyverno reached Docker Hub, found the image, and confirmed it was <strong>not</strong> signed with your <code>infra/cosign.pub</code> key.</p>
</li>
<li><p>The pod never exists: the attacker can't get a shell even if the image is pullable.</p>
</li>
</ul>
<p><strong>Verify:</strong></p>
<pre><code class="language-bash">kubectl get pod unsigned-test -n clearledger
# Error from server (NotFound): pods "unsigned-test" not found
</code></pre>
<p><strong>What you should NOT see</strong> (these mean the test didn't prove signature enforcement):</p>
<table>
<thead>
<tr>
<th>Symptom</th>
<th>What went wrong</th>
</tr>
</thead>
<tbody><tr>
<td>Pod created, then <code>ImagePullBackOff</code></td>
<td>Tag does not exist on Docker Hub, complete Step 1 first</td>
</tr>
<tr>
<td>Pod created and <code>Running</code></td>
<td>Image used <code>docker.io/...</code> instead of <code>index.docker.io/...</code></td>
</tr>
<tr>
<td>No <code>require-signed-images</code> in the error</td>
<td>Policy not applied, or <code>cosign.pub</code> not embedded in the policy YAML</td>
</tr>
</tbody></table>
<h4 id="heading-contrast-signed-image-is-allowed">Contrast: signed image is allowed:</h4>
<p>The previous test used an unsigned image, so Kyverno blocked it.</p>
<p>Your real ClearLedger images should be signed by the CI pipeline. If the pod also follows the security rules, Kyverno allows it to run.</p>
<p>You can check the image currently used by <code>auth-service</code>:</p>
<pre><code class="language-bash"># Your deployed tag (signed in CI) should start if spec is compliant:
kubectl get deployment auth-service -n clearledger \
  -o jsonpath='{.spec.template.spec.containers[0].image}'
# docker.io/veeno-demo/clearledger-auth-service:v0.1.0
</code></pre>
<p><strong>Example output:</strong></p>
<p><code>docker.io/veeno-demo/clearledger-auth-service:v0.1.0</code></p>
<p>Pods that were already running before the policies were applied will keep running. The important test is what happens when Kubernetes creates a new pod. New pods using signed ClearLedger images should pass Kyverno verification.</p>
<p>Take a screenshot of the Scenario 3 denial. It proves the cluster blocks unsigned images, not just that CI signs images.</p>
<h3 id="heading-45-verify-clearledger-still-works">4.5: Verify ClearLedger Still Works</h3>
<p>Kyverno enforces on new pod creation. Existing deployments that already passed admission (or were synced before policies existed) keep running. Confirm your app pods are healthy:</p>
<pre><code class="language-bash">kubectl get pods -n clearledger
</code></pre>
<pre><code class="language-plaintext">NAME                                    READY   STATUS    RESTARTS   AGE
auth-service-...                        1/1     Running   0          ...
frontend-...                            1/1     Running   0          ...
ledger-service-...                      1/1     Running   0          ...
notification-service-...                1/1     Running   0          ...
postgres-0                              1/1     Running   0          ...
redis-...                               1/1     Running   0          ...
</code></pre>
<p>If ingress is configured:</p>
<pre><code class="language-bash">curl -s http://clearledger.local/auth/health | jq .
# {"status": "ok", "service": "auth-service"}
</code></pre>
<p>ArgoCD should still show <strong>Synced</strong> and <strong>Healthy</strong>: GitOps and admission control work together, not against each other.</p>
<h3 id="heading-46-policy-exceptions-when-a-legitimate-workload-needs-a-bypass">4.6: Policy Exceptions (When a Legitimate Workload Needs a Bypass)</h3>
<p>Kyverno blocks every pod that violates a policy. But what happens when a legitimate workload needs to bypass a specific rule?</p>
<p>PostgreSQL is the example. The official Postgres Alpine image uses a specific internal user (UID 70) to manage its data directory. The <code>disallow-root-containers</code> policy requires every pod to set <code>runAsNonRoot: true</code>.</p>
<p>Postgres does set that. But if Kyverno is configured to also check specific UID ranges, or if the pod's security context doesn't satisfy the rule for any reason, Kyverno blocks it. The database can't start, and the entire application fails.</p>
<p>You can't weaken the policy cluster-wide to accommodate one database. That would let every pod bypass the rule. Instead, you create a <strong>PolicyException</strong>: a targeted exemption for exactly the pods that need it.</p>
<p>Open <a href="../infra/policies/exceptions/postgres-root-exception.yaml"><code>infra/policies/exceptions/postgres-root-exception.yaml</code></a> and read the comments. Here's what each section does:</p>
<p><strong>The</strong> <code>spec.exceptions</code> <strong>block</strong> identifies which policy and rule to bypass:</p>
<pre><code class="language-yaml">exceptions:
  - policyName: disallow-root-containers
    ruleNames:
      - check-runAsNonRoot
</code></pre>
<p>This says: "skip only the <code>check-runAsNonRoot</code> rule from the <code>disallow-root-containers</code> policy." Every other rule in that policy (and every other policy in the cluster) still enforces normally.</p>
<p><strong>The</strong> <code>spec.match</code> <strong>block</strong> limits which resources get the exception:</p>
<pre><code class="language-yaml">match:
  any:
    - resources:
        kinds:
          - Pod
        namespaces:
          - clearledger
        names:
          - postgres-*
</code></pre>
<p>Only pods named <code>postgres-*</code> (matching <code>postgres-0</code>, <code>postgres-1</code>, and so on), only in the <code>clearledger</code> namespace, only for the <code>Pod</code> resource kind. Everything else in the cluster still follows the strict policy.</p>
<p><strong>The annotations</strong> are documentation for your team and auditors:</p>
<pre><code class="language-yaml">annotations:
  reason: "Postgres alpine image requires UID 70 for data directory ownership"
  approved-by: "platform-team"
  review-date: "2026-01-01"
</code></pre>
<p>These have no technical effect: Kyverno ignores them. They exist so that six months from now, when someone asks "why does Postgres bypass this rule?", the answer is right there in the file.</p>
<p><strong>The rules for safe exceptions:</strong></p>
<ol>
<li><p><strong>Scope narrowly</strong>: target the exact resource that needs it, nothing more</p>
</li>
<li><p><strong>Commit to Git</strong>: the exception is reviewed in a pull request, tracked in version history, and auditable</p>
</li>
<li><p><strong>Never weaken the policy itself</strong>: the rule stays strict for everything else</p>
</li>
<li><p><strong>Review periodically</strong>: exceptions should be temporary if possible, and re-evaluated on a schedule</p>
</li>
</ol>
<p>Apply the exception <strong>only if</strong> Kyverno blocks your Postgres pods:</p>
<pre><code class="language-bash">kubectl apply -f infra/policies/exceptions/postgres-root-exception.yaml
</code></pre>
<p>Verify Kyverno still blocks other non-compliant pods (same denial as Scenario 1):</p>
<pre><code class="language-bash">cat &lt;&lt;EOF | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
  name: another-root-test
  namespace: clearledger
spec:
  containers:
    - name: test
      image: nginx:alpine
EOF
</code></pre>
<h3 id="heading-47-cis-benchmark-evidence-kube-bench">4.7: CIS Benchmark Evidence <code>kube-bench</code>)</h3>
<p>You already installed Kyverno and proved it blocks unsafe pods.</p>
<p>This step is different. <code>kube-bench</code> doesn't block pods and doesn't change the cluster. It only checks the Kubernetes node against the CIS benchmark and saves evidence.</p>
<p>Think of the difference like this:</p>
<table>
<thead>
<tr>
<th>Tool</th>
<th>What it checks</th>
<th>Question it answers</th>
</tr>
</thead>
<tbody><tr>
<td>Kyverno</td>
<td>Pods and workloads</td>
<td>"Is this pod allowed to run?"</td>
</tr>
<tr>
<td>kube-bench</td>
<td>Kubernetes node settings</td>
<td>"Is this Kubernetes node hardened?"</td>
</tr>
</tbody></table>
<p>Both are useful, but only Kyverno blocks workloads in this lab.</p>
<p>Run kube-bench:</p>
<pre><code class="language-bash">bash stages/stage-4-admission-control/scripts/run-kube-bench.sh
</code></pre>
<p>The script runs kube-bench as a Kubernetes Job and saves the report here:</p>
<pre><code class="language-text">stages/stage-4-admission-control/scripts/kube-bench-report.json
</code></pre>
<p>It also compares the result against this baseline:</p>
<pre><code class="language-text">stages/stage-4-admission-control/scripts/kube-bench-baseline.json
</code></pre>
<p>On MicroK8s, you'll see many <code>FAIL</code> and <code>WARN</code> lines. That's expected. The lab isn't asking you to fix every CIS warning on a single-node local VM.</p>
<p>What matters is the final result.</p>
<p>Pass looks like this:</p>
<pre><code class="language-text">kube-bench: 1 FAIL control(s) present (documented in baseline — no regressions).
kube-bench: no regressions vs baseline.
</code></pre>
<p>That means the known MicroK8s issues are documented, and your cluster didn't get worse.</p>
<p>If you see <code>REGRESSION</code> or <code>make check-4</code> fails on kube-bench, stop and investigate before Stage 5.</p>
<p>Optional: confirm the report file exists:</p>
<pre><code class="language-bash">ls -la stages/stage-4-admission-control/scripts/kube-bench-report.json
</code></pre>
<p>In production, you would either fix the CIS failures or document approved exceptions. In this lab, the baseline records the expected MicroK8s state.</p>
<h3 id="heading-48-health-check">4.8: Health Check</h3>
<pre><code class="language-bash">make check-4
</code></pre>
<p><strong>What you should see:</strong></p>
<pre><code class="language-plaintext">▶ Stage 4 — Admission Control (Kyverno)
  ✓ Kyverno is running
  ✓ Policy disallow-root-containers — Enforce mode
  ✓ Policy require-resource-limits — Enforce mode
  ✓ Policy require-signed-images — Enforce mode
  ✓ Policy disallow-privilege-escalation — Enforce mode
  ✓ Policy drop-all-capabilities — Enforce mode
  ✓ Kyverno correctly rejects pods without securityContext
  ✓ kube-bench baseline exists (...)

All checks passed. Ready for the next stage.
</code></pre>
<p>If kube-bench reports regressions, run the script manually and update the baseline after reviewing. That diff is audit evidence.</p>
<p>If Kyverno install, policies, break-it scenarios, or <code>make check-4</code> fail, see <a href="https://github.com/Osomudeya/clearledger/blob/main/docs/troubleshooting.md">troubleshooting.md. Stage 4</a>.</p>
<h3 id="heading-stage-4-complete-done-checklist-move-to-stage-5">Stage 4 Complete: Done Checklist (Move to Stage 5)</h3>
<p>You're <strong>done with Stage 4</strong> when all of these are true:</p>
<table>
<thead>
<tr>
<th>#</th>
<th>Check</th>
<th>How to verify</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>Kyverno running</td>
<td><code>kubectl get pods -n kyverno</code> — four controllers <code>Running</code></td>
</tr>
<tr>
<td>2</td>
<td>Policies applied</td>
<td><code>kubectl get clusterpolicy</code> — five policies, <code>READY: True</code>, <code>Enforce</code></td>
</tr>
<tr>
<td>3</td>
<td>Root pod blocked</td>
<td>Scenario 1 denial in terminal (screenshot for portfolio)</td>
</tr>
<tr>
<td>4</td>
<td>Unsigned image blocked</td>
<td>Scenario 3 denial — push <code>unsigned-test</code> tag first, use <code>index.docker.io/</code></td>
</tr>
<tr>
<td>5</td>
<td>App still healthy</td>
<td><code>kubectl get pods -n clearledger</code> — all app pods <code>Running</code></td>
</tr>
<tr>
<td>6</td>
<td>Health check green</td>
<td><code>make check-4</code> ends with <code>All checks passed. Ready for the next stage.</code></td>
</tr>
</tbody></table>
<p><strong>Portfolio screenshots (optional):</strong> root-pod denial (§4.4 Scenario 1), unsigned-image denial (§4.4 Scenario 3), and <code>kubectl get clusterpolicy</code> showing five <code>Enforce</code> policies.</p>
<p>Not yet: SLSA attestation (optional), Vault secrets (Stage 5), network policies (Stage 6). Passwords still live in Kubernetes Secrets. Stage 5 moves them into Vault.</p>
<h3 id="heading-what-you-learned-in-stage-4">What You Learned in Stage 4</h3>
<ul>
<li><p>The difference between CI scanning (before merge) and admission control (at the cluster gate)</p>
</li>
<li><p>What Kyverno is: a policy engine that intercepts every Kubernetes API request</p>
</li>
<li><p>That enforcement means the bad resource never exists, not "we detected it after the fact"</p>
</li>
<li><p>How to read a Kyverno denial: policy name → rule name → JSON path that failed</p>
</li>
<li><p>How to write and apply cluster-wide security policies as YAML</p>
</li>
<li><p>How to scope a PolicyException without weakening the policy for everyone else</p>
</li>
<li><p>That operational issues (Helm, image pulls, registry URL format) affect whether controls actually fire</p>
</li>
<li><p><strong>Why both CI and admission control are needed:</strong> CI catches problems in your code while Kyverno catches everything else that touches the cluster</p>
</li>
<li><p><strong>Evidence beats configuration:</strong> a policy file in Git means nothing: the break-it denials are proof CIS controls are enforced, not just documented.</p>
</li>
</ul>
<p><strong>What you can now put on your CV / say in an interview:</strong></p>
<blockquote>
<p>Enforced admission control with Kyverno: blocking root containers, privilege escalation, unsigned images, and missing resource limits at deploy time: mapped to CIS Kubernetes benchmarks.</p>
</blockquote>
<p><code>make snapshot STAGE=4 &amp;&amp; make snapshots</code>. Confirm <code>clearledger.stage4</code>. See <a href="#heading-how-to-save-your-progress">How to Save Your Progress</a>.</p>
<h2 id="heading-stage-5-secrets-management-vault">Stage 5: Secrets Management (Vault)</h2>
<p>By the end of this stage, sensitive values no longer live in Git or in etcd-backed Kubernetes Secrets: Vault holds them centrally and injects them into pods only when they start.</p>
<p><strong>Your goal:</strong> remove <code>auth-service-secret</code> and <code>ledger-service-secret</code> from the cluster.</p>
<p>Login and API calls must still work because Vault injects credentials at pod startup. That's the moment secrets management clicks.</p>
<p><strong>Before you start</strong>, confirm Stage 4 is solid: <code>make check-4</code> passes, all five Kyverno policies are enforcing, and the app responds at <code>http://clearledger.local</code>. Fix any crash-looping pods before installing Vault.</p>
<h3 id="heading-what-changes-in-this-stage">What Changes in This Stage</h3>
<p>Right now, database passwords and JWT keys sit in <code>secret.yaml</code> files on GitHub and in Kubernetes Secrets inside the cluster. In Stage 5 you move those values into <strong>HashiCorp Vault</strong> and teach the app to read them a different way.</p>
<p>When an auth or ledger pod starts, the <strong>Vault agent injector</strong> adds a small sidecar container. That sidecar logs into Vault using the pod’s own service account, fetches the password and JWT, and writes them as files under <code>/vault/secrets/</code>.</p>
<p>Your app already knows how to read those paths. It's the same data that used to arrive via <code>secretKeyRef</code>, just delivered at runtime instead of pulled from a Kubernetes Secret object.</p>
<p>Once migration is complete, sensitive values live in <strong>Vault</strong> (the long-term store) and briefly on the <strong>pod filesystem</strong> while the container runs. They're not in Git anymore. You remove <code>secret.yaml</code> from <code>clearledger-infra</code> and ArgoCD syncs deployments that point at Vault instead.</p>
<p>To load Vault the first time, you copy a template to a local <code>.env</code> file (§5.1). That file is gitignored. You run <code>seed-vault-secrets.sh</code> once to copy those values into Vault.</p>
<p>Real secret values aren't written into committed scripts. The scripts read secrets from your local <code>.env</code> file or from your terminal, so passwords and tokens stay out of Git.</p>
<h3 id="heading-do-the-steps-in-this-order">Do the Steps in This Order</h3>
<p>Each step depends on the one before it. Skipping ahead is the most common way to get red auth/ledger pods that look like a broken app but really mean “Vault is not ready yet.”</p>
<ol>
<li><p><strong>§5.1</strong>: copy <code>stages/stage-5-secrets-management/.env.example</code> to <code>.env</code>, then fill it with your cluster passwords</p>
</li>
<li><p><strong>§5.2</strong>: install Vault and the agent injector with Helm</p>
</li>
<li><p><strong>§5.3</strong>. Run <code>setup.sh</code>, then <code>seed-vault-secrets.sh</code> (passwords now live in Vault)</p>
</li>
<li><p><strong>§5.4</strong>: push Vault-enabled deployments to <code>clearledger-infra</code>. Let ArgoCD sync.</p>
</li>
<li><p><strong>§5.5</strong>. Wait for <strong>2/2</strong> pods (app + Vault sidecar), then delete the old Kubernetes Secrets</p>
</li>
<li><p><strong>§5.5b</strong>: ArgoCD <strong>Synced / Healthy</strong> (after secret delete. OutOfSync before delete is normal)</p>
</li>
<li><p><strong>§5.6</strong>. Confirm login works and credentials appear under <code>/vault/secrets/</code> inside the pod</p>
</li>
</ol>
<p>Start at <strong>§5.1</strong>. If anything fails, read <code>troubleshooting.md.</code> before changing manifests.</p>
<h3 id="heading-51-create-env-local-only-never-commit">5.1: Create <code>.env</code> (Local Only, Never Commit)</h3>
<p>This file holds two things: a dev Vault root token for Helm (§5.2), and the passwords you'll load into Vault in §5.3.</p>
<p>It stays on your machine only. Never commit it. The <code>SEED_*</code> values must match what the app uses today so login still works after you delete Kubernetes Secrets later.</p>
<p>Two different files. <strong>Don't mix them up:</strong></p>
<table>
<thead>
<tr>
<th>File</th>
<th>What it is</th>
</tr>
</thead>
<tbody><tr>
<td><code>stages/stage-5-secrets-management/.env.example</code></td>
<td>Blank template in the repo (empty fields). Copy this in step 1.</td>
</tr>
<tr>
<td><code>stages/stage-5-secrets-management/.env</code></td>
<td>Your real file (gitignored). You create it and fill it in steps 2–3.</td>
</tr>
</tbody></table>
<p>The sample block at the bottom of this section is only a picture of what a completed <code>.env</code> looks like: don't copy those placeholder passwords unless they happen to match your cluster.</p>
<h4 id="heading-step-1-copy-the-template-to-env">Step 1: copy the template to <code>.env</code></h4>
<pre><code class="language-bash">cp stages/stage-5-secrets-management/.env.example \
   stages/stage-5-secrets-management/.env
</code></pre>
<p>That gives you a file with empty <code>VAULT_TOKEN=</code> and <code>SEED_*=</code> lines. Open it in your editor for steps 2–3.</p>
<h4 id="heading-step-2-read-the-current-passwords-from-the-cluster">Step 2: read the current passwords from the cluster</h4>
<p>Run these from the repo root. Each command prints one value: copy the output into <code>.env</code> in step 3.</p>
<pre><code class="language-bash"># → paste as SEED_AUTH_DATABASE_URL
kubectl get secret auth-service-secret -n clearledger \
  -o jsonpath='{.data.database_url}' | base64 -d; echo

# → paste as SEED_AUTH_JWT_SECRET
kubectl get secret auth-service-secret -n clearledger \
  -o jsonpath='{.data.jwt_secret}' | base64 -d; echo

# → paste as SEED_LEDGER_DATABASE_URL
kubectl get secret ledger-service-secret -n clearledger \
  -o jsonpath='{.data.database_url}' | base64 -d; echo
</code></pre>
<h4 id="heading-step-3-fill-in-env">Step 3: fill in <code>.env</code></h4>
<table>
<thead>
<tr>
<th>Variable</th>
<th>What to put</th>
</tr>
</thead>
<tbody><tr>
<td><code>VAULT_TOKEN</code></td>
<td>Any dev-only string you choose (for example, <code>my-dev-root-token</code>): same value in §5.2 Helm install</td>
</tr>
<tr>
<td><code>SEED_AUTH_DATABASE_URL</code></td>
<td>Output of first command above</td>
</tr>
<tr>
<td><code>SEED_AUTH_JWT_SECRET</code></td>
<td>Output of second command</td>
</tr>
<tr>
<td><code>SEED_LEDGER_DATABASE_URL</code></td>
<td>Output of third command</td>
</tr>
</tbody></table>
<p><strong>Sample only: shape of a completed</strong> <code>.env</code> (use your kubectl output from step 2, not these example strings unless they match):</p>
<pre><code class="language-text">VAULT_TOKEN=my-dev-root-token
SEED_AUTH_DATABASE_URL=postgresql://clearledger:changeme-stage0@postgres:5432/clearledger
SEED_AUTH_JWT_SECRET=stage0-jwt-secret-change-in-production
SEED_LEDGER_DATABASE_URL=postgresql://clearledger:changeme-stage0@postgres:5432/clearledger
</code></pre>
<p>If <code>auth-service-secret</code> is already deleted (you skipped ahead: recover like this):</p>
<pre><code class="language-bash"># Database URL from Postgres bootstrap secret (lab default password is often changeme-stage0)
PG_PASS=$(kubectl get secret postgres-secret -n clearledger \
  -o jsonpath='{.data.password}' | base64 -d)
echo "postgresql://clearledger:${PG_PASS}@postgres:5432/clearledger"
# Use that line for both SEED_AUTH_DATABASE_URL and SEED_LEDGER_DATABASE_URL

# JWT: same value you used at Stage 0, or read from Vault if you already seeded:
kubectl exec -n vault vault-0 -- vault kv get -field=jwt_secret clearledger/auth-service 2&gt;/dev/null \
  || echo "(set SEED_AUTH_JWT_SECRET manually — must match tokens already issued)"
</code></pre>
<p>Continue to <strong>§5.2</strong> once <code>.env</code> has all four variables set.</p>
<h3 id="heading-52-install-vault-and-the-agent-injector">5.2: Install Vault and the Agent Injector</h3>
<pre><code class="language-bash">set -a &amp;&amp; source stages/stage-5-secrets-management/.env &amp;&amp; set +a

helm repo add hashicorp https://helm.releases.hashicorp.com &amp;&amp; helm repo update

# First install:
helm install vault hashicorp/vault \
  --namespace vault --create-namespace \
  --set server.dev.enabled=true \
  --set server.dev.devRootToken="${VAULT_TOKEN}" \
  --set ui.enabled=true \
  --set injector.enabled=true

# If helm install fails with "cannot re-use a name", use upgrade instead:
# helm upgrade --install vault hashicorp/vault \
#   --namespace vault --create-namespace \
#   --set server.dev.enabled=true \
#   --set server.dev.devRootToken="${VAULT_TOKEN}" \
#   --set ui.enabled=true \
#   --set injector.enabled=true

kubectl wait --for=condition=ready pod \
  -l app.kubernetes.io/name=vault -n vault --timeout=120s
kubectl wait --for=condition=ready pod \
  -l app.kubernetes.io/name=vault-agent-injector -n vault --timeout=120s

kubectl apply -f stages/stage-5-secrets-management/infra/vault-ingress.yaml
</code></pre>
<p>Open <a href="http://vault.local"><code>http://vault.local</code></a> in your browser. Log in with the value you set as <code>VAULT_TOKEN</code> in <code>stages/stage-5-secrets-management/.env</code>. For example, if your <code>.env</code> has <code>VAULT_TOKEN=my-dev-root-token</code>, use <code>my-dev-root-token</code> as the Vault login token.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/8a2b0002-2ee3-4b04-89f0-e43da18fc9b9.png" alt="screenshot showing vault UI" style="display: block;" width="1301" height="696" loading="lazy">

<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/ec5fdb0b-7258-4e41-aeb5-adaa8754dbf2.png" alt="screenshot showing vault UI" style="display: block;" width="1283" height="703" loading="lazy">

<p><strong>Verify: list Vault pods:</strong></p>
<pre><code class="language-bash">kubectl get pods -n vault
</code></pre>
<p><strong>Expected: Vault pods:</strong></p>
<pre><code class="language-text">NAME                                   READY   STATUS    RESTARTS   AGE
vault-0                                1/1     Running   0          1m
vault-agent-injector-8d6b668b4-xxxxx   1/1     Running   0          1m
</code></pre>
<p><strong>If</strong> <code>helm install</code> <strong>fails with “cannot re-use a name”</strong>: Vault is already installed. Use the <code>helm upgrade --install</code> block above.</p>
<h3 id="heading-53-configure-vault-platform-seed-kv">5.3: Configure Vault (Platform + Seed KV)</h3>
<p>Run both scripts in order. Each reads <code>VAULT_TOKEN</code> from your <code>.env</code>.</p>
<pre><code class="language-bash">bash stages/stage-5-secrets-management/infra/vault/setup.sh
bash stages/stage-5-secrets-management/infra/vault/seed-vault-secrets.sh
</code></pre>
<p><code>setup.sh</code>: prepares Vault for the cluster: Kubernetes auth, the KV secret store, policies, and roles so auth/ledger pods <em>can</em> fetch secrets later. It doesn't write your database passwords yet and nothing goes to Git.</p>
<p><code>seed-vault-secrets.sh</code>: takes the <code>SEED_*</code> lines from <code>.env</code> and stores them in Vault at <code>clearledger/data/auth-service</code> and <code>clearledger/data/ledger-service</code>. It doesn't echo those values to the terminal.</p>
<p>Re-running either script is safe for the lab.</p>
<p><strong>Expected,</strong> <code>setup.sh</code> <strong>(tail):</strong></p>
<pre><code class="language-text">==&gt; Enabling Kubernetes auth method...
==&gt; Configuring Kubernetes auth...
==&gt; Enabling KV secrets engine...
==&gt; Creating Vault policies...
==&gt; Creating Kubernetes auth roles...
==&gt; Applying RBAC + ServiceAccounts...

✓ Vault platform setup complete (no secrets written yet).
  Next: bash stages/stage-5-secrets-management/infra/vault/seed-vault-secrets.sh
</code></pre>
<p><strong>Expected,</strong> <code>seed-vault-secrets.sh</code><strong>:</strong></p>
<pre><code class="language-text">==&gt; Logging into Vault...
==&gt; Writing secrets to Vault KV (values are not printed)...
======== Secret Path ========
clearledger/data/auth-service
======= Metadata =======
Key                Value
---                -----
created_time       2026-06-01T15:31:53.538991153Z
version            1
✓ Secrets stored at clearledger/data/auth-service and clearledger/data/ledger-service
</code></pre>
<p><strong>Verify metadata only</strong> (no secret values printed):</p>
<pre><code class="language-bash">kubectl exec -n vault vault-0 -- vault kv metadata get clearledger/auth-service
</code></pre>
<pre><code class="language-text">Key                     Value
---                     -----
cas_required            false
created_time            2026-06-01T15:31:53.538991153Z
current_version         1
delete_version_after    0s
max_versions            0
oldest_version          0
updated_time            2026-06-01T15:31:53.538991153Z
</code></pre>
<h3 id="heading-54-gitops-update-clearledger-infra-fixes-argocd-outofsync">5.4: GitOps: Update <code>clearledger-infra</code> (Fixes ArgoCD OutOfSync)</h3>
<p>ArgoCD deploys from your <code>clearledger-infra</code> GitHub repo, not from the main <code>clearledger</code> app repo where you're working now. You edit manifests here first, then copy the same changes to <code>clearledger-infra</code> so ArgoCD can sync them. Work slowly and verify after each sub-step.</p>
<h4 id="heading-54a-update-manifests-in-the-app-repo-clearledger">5.4a. Update manifests in the app repo (<code>clearledger</code>)</h4>
<pre><code class="language-bash">cp stages/stage-5-secrets-management/infra/manifests/auth-service/deployment.yaml \
   infra/manifests/auth-service/deployment.yaml

cp stages/stage-5-secrets-management/infra/manifests/ledger-service/deployment.yaml \
   infra/manifests/ledger-service/deployment.yaml\

mkdir -p infra/manifests/vault

cp infra/deferred-by-stage/stage-5-secrets-management/vault/rotation-cronjob.yaml \
   infra/manifests/vault/rotation-cronjob.yaml

rm -f infra/manifests/auth-service/secret.yaml infra/manifests/ledger-service/secret.yaml
</code></pre>
<h4 id="heading-54b-edit-inframanifestskustomizationyaml-by-hand">5.4b. Edit <code>infra/manifests/kustomization.yaml</code> by hand</h4>
<p>Open the file in your editor. In the <code>resources:</code> list:</p>
<ul>
<li><p><strong>Remove</strong> the app secret entries: delete these two lines, or comment them out with <code>#</code> (both work, as Kustomize ignores <code>#</code> lines):</p>
<pre><code class="language-yaml">- auth-service/secret.yaml
- ledger-service/secret.yaml
</code></pre>
</li>
<li><p><strong>Add</strong> this line (with the other resources):</p>
<pre><code class="language-yaml">- vault/rotation-cronjob.yaml
</code></pre>
</li>
</ul>
<p>Leave <code>postgres/postgres-secret.yaml</code>, that is Postgres bootstrap only, not app credentials.</p>
<p>Save. Verify:</p>
<pre><code class="language-bash"># Active (uncommented) app secret lines must be gone — postgres-secret is OK
grep -E '^[[:space:]]*-[[:space:]]+(auth-service|ledger-service)/secret\.yaml' \
  infra/manifests/kustomization.yaml &amp;&amp; echo "STOP: app secrets still active" || echo "OK"

grep vault/rotation-cronjob.yaml infra/manifests/kustomization.yaml
grep vault.hashicorp infra/manifests/auth-service/deployment.yaml | head -1
kustomize build infra/manifests &gt;/dev/null &amp;&amp; echo "OK: kustomize build"
</code></pre>
<p>Expected: <code>OK</code>, rotation cronjob listed, first line shows <code>vault.hashicorp.com/agent-inject</code>, kustomize build succeeds.</p>
<p>Commit in the <strong>app</strong> repo when ready: <code>git add infra/manifests &amp;&amp; git commit -m "feat(stage-5): Vault deployments in canonical manifests"</code>.</p>
<h4 id="heading-54c-push-the-same-changes-to-clearledger-infra">5.4c. Push the same changes to <code>clearledger-infra</code></h4>
<pre><code class="language-bash">git clone https://github.com/YOUR_USERNAME/clearledger-infra.git /tmp/clearledger-infra
</code></pre>
<p>If clone fails with <code>destination path '/tmp/clearledger-infra' already exists</code> (you cloned in §1.3 or an earlier step), reuse that folder. Don't clone again:</p>
<pre><code class="language-bash">cd /tmp/clearledger-infra &amp;&amp; git pull &amp;&amp; cd -
</code></pre>
<p>Or start fresh: <code>rm -rf /tmp/clearledger-infra</code> then run <code>git clone</code> again.</p>
<p><strong>Run the</strong> <code>cp</code> <strong>commands from the main</strong> <code>clearledger</code> <strong>app repo</strong>, not from <code>/tmp/clearledger-infra</code>. Your shell prompt should say <code>clearledger</code>, not <code>clearledger-infra</code>. The source path <code>infra/manifests/...</code> only exists in the app repo.</p>
<pre><code class="language-bash">cd ~/clearledger    # main app repo — adjust path if yours differs

cp infra/manifests/auth-service/deployment.yaml /tmp/clearledger-infra/manifests/auth-service/
cp infra/manifests/ledger-service/deployment.yaml /tmp/clearledger-infra/manifests/ledger-service/
mkdir -p /tmp/clearledger-infra/manifests/vault
cp infra/manifests/vault/rotation-cronjob.yaml /tmp/clearledger-infra/manifests/vault/
cp infra/manifests/kustomization.yaml /tmp/clearledger-infra/manifests/kustomization.yaml
rm -f /tmp/clearledger-infra/manifests/auth-service/secret.yaml
rm -f /tmp/clearledger-infra/manifests/ledger-service/secret.yaml

cd /tmp/clearledger-infra
git add -A
git status
git commit -m "feat(stage-5): Vault injection; remove app secrets from GitOps"
git push
cd -
</code></pre>
<p><strong>✋ Hands-on checkpoint. Stage 5 GitOps landed</strong></p>
<pre><code class="language-bash">git clone --depth 1 https://github.com/YOUR_USERNAME/clearledger-infra.git /tmp/verify-s5
test ! -f /tmp/verify-s5/manifests/auth-service/secret.yaml &amp;&amp; echo "OK: app secret removed from Git"
grep vault.hashicorp /tmp/verify-s5/manifests/auth-service/deployment.yaml | head -1
grep vault/rotation-cronjob.yaml /tmp/verify-s5/manifests/kustomization.yaml
rm -rf /tmp/verify-s5
</code></pre>
<p>Expected: <code>OK</code>, Vault annotation present, rotation job in kustomization.</p>
<p><strong>Expected,</strong> <code>git status</code> <strong>before commit (step 5.4c):</strong></p>
<pre><code class="language-text">modified:   manifests/auth-service/deployment.yaml
modified:   manifests/ledger-service/deployment.yaml
modified:   manifests/kustomization.yaml
new file:   manifests/vault/rotation-cronjob.yaml
deleted:    manifests/auth-service/secret.yaml
deleted:    manifests/ledger-service/secret.yaml
</code></pre>
<p>After <code>git push</code>, ArgoCD will roll out Vault-enabled deployments automatically. <strong>Continue to §5.5</strong>. Don't expect <strong>Synced</strong> yet, as app secrets are still in the cluster until you delete them there.</p>
<p><strong>Common rollout failures:</strong></p>
<table>
<thead>
<tr>
<th>Symptom</th>
<th>Fix</th>
</tr>
</thead>
<tbody><tr>
<td><code>Duplicate value: "vault-secrets"</code></td>
<td>Do <strong>not</strong> declare a <code>vault-secrets</code> volume in <code>deployment.yaml</code>: the injector creates it</td>
</tr>
<tr>
<td><code>Service appeared 2 times</code></td>
<td>Keep <code>Service</code> only in <code>service.yaml</code>, not at the bottom of <code>deployment.yaml</code></td>
</tr>
<tr>
<td>Kyverno <code>containers/0</code> <code>runAsNonRoot</code></td>
<td>Add <code>runAsNonRoot: true</code> on the <strong>app</strong> container <code>securityContext</code>, not only on <code>spec.securityContext</code></td>
</tr>
<tr>
<td>Pods stuck <code>1/1</code> (no sidecar)</td>
<td>Confirm <code>injector.enabled=true</code> and deployment has <code>vault.hashicorp.com/agent-inject: "true"</code></td>
</tr>
<tr>
<td><code>permission denied</code> in vault-agent-init</td>
<td>Run <code>setup.sh</code> : K8s auth role not bound to service account</td>
</tr>
<tr>
<td>ArgoCD <strong>Sync failed</strong> on <code>CronJob/vault-secret-rotation</code></td>
<td>Kyverno blocked the job: <code>infra/manifests/vault/rotation-cronjob.yaml</code> must include <code>runAsNonRoot</code>, <code>allowPrivilegeEscalation: false</code>, <code>capabilities.drop: [ALL]</code>, and CPU/memory limits. Push fix to <code>clearledger-infra</code>.</td>
</tr>
</tbody></table>
<h3 id="heading-55-wait-for-vault-injected-pods-then-delete-k8s-app-secrets">5.5: Wait for Vault-injected Pods, Then Delete K8s App Secrets</h3>
<p><strong>Wait until auth/ledger show Vault sidecars</strong> (<code>READY 2/2</code> = app + vault-agent):</p>
<pre><code class="language-bash">kubectl get pods -n clearledger -l app=auth-service
kubectl get pods -n clearledger -l app=ledger-service
</code></pre>
<p><strong>Expected:</strong></p>
<pre><code class="language-text">NAME                            READY   STATUS    RESTARTS   AGE
auth-service-5756d9fcb9-bmdlr   2/2     Running   0          2m
auth-service-5756d9fcb9-jtgss   2/2     Running   0          2m
</code></pre>
<p>Inspect sidecar pulled secrets (init container logs):</p>
<pre><code class="language-bash">kubectl logs -n clearledger \
  $(kubectl get pod -n clearledger -l app=auth-service -o name | head -1) \
  -c vault-agent-init
# ... Authentication successful, rendering templates ...
</code></pre>
<p><strong>Only after pods are 2/2</strong>, delete app Secrets:</p>
<pre><code class="language-bash">kubectl delete secret auth-service-secret ledger-service-secret -n clearledger
</code></pre>
<p><strong>Expected: secrets remaining:</strong></p>
<pre><code class="language-bash">kubectl get secret -n clearledger
</code></pre>
<pre><code class="language-text">NAME              TYPE     DATA   AGE
postgres-secret   Opaque   2      6d
</code></pre>
<p><code>postgres-secret</code> is Postgres bootstrap only, not app credentials. That stays until you harden Postgres separately.</p>
<p>If delete says <code>NotFound</code>: secrets were already removed. Continue to §5.6.</p>
<h3 id="heading-55b-argocd-should-be-synced-after-secret-delete">5.5b: ArgoCD Should Be Synced After Secret Delete</h3>
<p>Run this after §5.5, not right after §5.4. Before you delete app Secrets, OutOfSync is normal. Git no longer lists <code>auth-service-secret</code> / <code>ledger-service-secret</code>, but they still exist in the cluster until you delete them in the step above.</p>
<pre><code class="language-bash">kubectl get application clearledger -n argocd \
  -o jsonpath='sync={.status.sync.status} health={.status.health.status}{"\n"}'
</code></pre>
<p><strong>Before secret delete:</strong> expect <code>sync=OutOfSync health=Healthy</code> or <code>Progressing</code> while Vault pods roll out. That's fine if auth/ledger are <strong>2/2</strong>.</p>
<p><strong>After secret delete</strong>, hard-refresh and sync if still OutOfSync:</p>
<pre><code class="language-bash">kubectl annotate application clearledger -n argocd argocd.argoproj.io/refresh=hard --overwrite
argocd app sync clearledger --grpc-web --prune
</code></pre>
<p>If sync says <strong>another operation is already in progress</strong>, wait a minute: ArgoCD auto-sync is already running.</p>
<p>Wait until:</p>
<pre><code class="language-bash">kubectl get application clearledger -n argocd \
  -o jsonpath='{.status.sync.status} {.status.health.status}{"\n"}'
# Synced Healthy
</code></pre>
<p>Don't update app deployments with <code>kubectl apply</code> after ArgoCD is managing them. ArgoCD keeps the cluster matched to <code>clearledger-infra</code>. If you change a deployment by hand, ArgoCD may revert it. For Stage 5, update the manifests in Git and let ArgoCD sync the Vault-enabled deployments.</p>
<h3 id="heading-56-login-and-injected-files">5.6: Login and Injected Files</h3>
<pre><code class="language-bash">kubectl exec -n clearledger \
  $(kubectl get pod -n clearledger -l app=auth-service -o name | head -1) \
  -c auth-service -- ls /vault/secrets/
</code></pre>
<pre><code class="language-text">database_url
jwt_secret
</code></pre>
<pre><code class="language-bash">curl -s -X POST http://clearledger.local/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"test@clearledger.io","password":"SecurePass123"}' | jq .
</code></pre>
<p><strong>Expected:</strong></p>
<pre><code class="language-json">{
  "access_token": "&lt;jwt-returned-by-auth-service&gt;",
  "token_type": "bearer"
}
</code></pre>
<p><strong>Take a screenshot:</strong> working login JSON + <code>kubectl get secret -n clearledger</code> showing no <code>auth-service-secret</code> / <code>ledger-service-secret</code>.</p>
<h3 id="heading-57-health-check">5.7: Health Check</h3>
<pre><code class="language-bash">make check-5
</code></pre>
<p><strong>What you should see:</strong></p>
<blockquote>
<p><code>make check-5</code> re-runs Stage 4 checks first, that is expected. Look for the Stage 5 block below to confirm Vault is working.</p>
</blockquote>
<pre><code class="language-text">▶ Stage 4: Admission Control (Kyverno)
  ✓ Kyverno is running
  ✓ Policy disallow-root-containers — Enforce mode
  ...
  ✓ kube-bench matches baseline (no new FAIL regressions)

▶ Stage 5: Secrets Management (Vault)
  ✓ Vault pod is running
  ✓ Vault agent injector is running
  ✓ Vault is unsealed
  ✓ Vault Kubernetes auth method is enabled
  ✓ auth-service-secret removed — Vault is the secret source
  ✓ Vault injected /vault/secrets/database_url into auth-service

All checks passed. Ready for the next stage.
</code></pre>
<p>If Vault injection or ArgoCD sync fails, see <code>troubleshooting.md</code>.</p>
<h3 id="heading-stage-5-is-done-checklist-before-moving-to-stage-6">Stage 5 is Done: Checklist Before Moving to Stage 6</h3>
<table>
<thead>
<tr>
<th>#</th>
<th>Check</th>
<th>How to verify</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>Secrets in Vault only</td>
<td><code>vault kv metadata get clearledger/auth-service</code> shows <code>current_version &gt;= 1</code></td>
</tr>
<tr>
<td>2</td>
<td>No app secrets in infra Git</td>
<td><code>secret.yaml</code> absent from <code>clearledger-infra/manifests/auth-service/</code> and <code>ledger-service/</code></td>
</tr>
<tr>
<td>3</td>
<td>ArgoCD synced</td>
<td><code>Synced Healthy</code> on Application <code>clearledger</code></td>
</tr>
<tr>
<td>4</td>
<td>K8s app secrets deleted</td>
<td><code>kubectl get secret -n clearledger</code> no auth/ledger app secrets</td>
</tr>
<tr>
<td>5</td>
<td>Injection works</td>
<td>Auth pods <code>2/2</code>; <code>ls /vault/secrets/</code> shows <code>database_url</code>, <code>jwt_secret</code></td>
</tr>
<tr>
<td>6</td>
<td>App works</td>
<td>Login curl returns <code>access_token</code></td>
</tr>
<tr>
<td>7</td>
<td>Health check</td>
<td><code>make check-5</code> ends with <code>All checks passed. Ready for the next stage.</code></td>
</tr>
</tbody></table>
<p>Stage 5 moves app credentials out of Git and Kubernetes Secrets. It doesn't make Vault production-grade yet. This lab still uses Vault dev mode, not HA or auto-unseal.</p>
<p>Also, a running pod can still read the files under <code>/vault/secrets/</code> because the app needs those credentials to work. That's normal. Stage 6 adds Falco so you can detect suspicious runtime access.</p>
<h3 id="heading-what-you-learned-in-stage-5">What You Learned in Stage 5</h3>
<ul>
<li><p>Kubernetes Secrets aren't enough for real secret management.</p>
</li>
<li><p>Vault now stores the app credentials.</p>
</li>
<li><p><code>.env</code> was only used locally to load the first secrets into Vault. It's never committed.</p>
</li>
<li><p>Vault injects secrets into the pod when the app starts.</p>
</li>
<li><p><code>clearledger-infra</code> must stop storing <code>secret.yaml</code>, because ArgoCD deploys from that repo.</p>
</li>
<li><p>The order matters: install Vault, seed secrets, update GitOps, wait for healthy pods, then delete old Kubernetes Secrets.</p>
</li>
</ul>
<p><strong>What you can now say in an interview:</strong></p>
<blockquote>
<p>I replaced Kubernetes Secrets with HashiCorp Vault agent injection, removed app credentials from Git and Kubernetes Secrets, and verified the app still worked after Vault injected the credentials at runtime.</p>
</blockquote>
<p>Save your progress:</p>
<pre><code class="language-bash">make snapshot STAGE=5 &amp;&amp; make snapshots
</code></pre>
<p>Confirm <code>clearledger.stage5</code> appears in the snapshot list.</p>
<h2 id="heading-stage-6-runtime-security-falco">Stage 6 — Runtime Security (Falco)</h2>
<p>Stages 1–5 secured what gets deployed and how secrets are stored. Stage 6 watches what happens inside running containers after they start.</p>
<p>Your goal is to learn what runtime security catches and why it matters, then prove it by triggering a Falco alert and reading it the way an on-call engineer would.</p>
<p>CI, Kyverno, and Vault all act before or at pod startup. Falco fills the gap they leave open. It watches what running software actually does inside the container. That's the layer incident response and forensics care about, not just another chart to install.</p>
<p><strong>Before you start Stage 6:</strong></p>
<ul>
<li><p><code>make check-5</code> passes</p>
</li>
<li><p>Login and transactions still work at <code>http://clearledger.local</code></p>
</li>
<li><p>Platform pods have low restart counts</p>
</li>
</ul>
<p>You're done with Stage 6 when:</p>
<ul>
<li><p>You trigger at least one Falco alert</p>
</li>
<li><p>You apply the network policies</p>
</li>
<li><p><code>make check-6</code> passes</p>
</li>
</ul>
<p>Then save your VM:</p>
<pre><code class="language-bash">make snapshot STAGE=6
make snapshots
</code></pre>
<h3 id="heading-do-the-steps-in-this-order">Do the Steps in This Order</h3>
<p>Each step depends on the one before it. Don't run <code>make check-6</code> until §6.4. It checks network policies you haven't applied yet.</p>
<ol>
<li><p><strong>§6.1:</strong> <code>bash stages/stage-6-runtime-security/scripts/install-falco.sh</code>. Confirm <code>falco-*</code> pods <code>2/2 Running</code> and custom rules loaded.</p>
</li>
<li><p><strong>§6.2:</strong> <code>make demo-6</code>: read <code>✓ Runtime detection confirmed</code> in the terminal</p>
</li>
<li><p><strong>§6.3</strong> (optional) manual break-it scenarios (skip if <code>make demo-6</code> already worked)</p>
</li>
<li><p><strong>§6.4:</strong> <code>kubectl apply -f infra/deferred-by-stage/stage-6-runtime-security/netpol/network-policies.yaml</code>. Confirm <code>curl http://clearledger.local/</code> returns 200.</p>
</li>
<li><p><strong>§6.6:</strong> <code>make check-6</code></p>
</li>
</ol>
<p>Start at <strong>§6.1</strong>. If anything fails, see <code>troubleshooting.md</code>.</p>
<p><strong>Optional reading:</strong> <a href="#heading-how-stage-6-fits-the-full-stack-optional-reading">How Stage 6 fits the full stack</a>: why Falco and netpol exist and how they differ from Stages 3–5.</p>
<h3 id="heading-if-you-get-stuck-in-stage-6">If You Get Stuck in Stage 6</h3>
<p>Stage 6 has three jobs:</p>
<ol>
<li><p>Install Falco</p>
</li>
<li><p>Trigger one test alert</p>
</li>
<li><p>Apply network policies</p>
</li>
</ol>
<p>Don't worry about every row in the Falco UI. The UI may show noise. You pass the Falco part when you can find one alert from your demo, either in the terminal or in the UI.</p>
<p>For the portfolio screenshot, open:</p>
<p><code>http://falco.local</code></p>
<p>Login:</p>
<ul>
<li><p>Username: <code>admin</code></p>
</li>
<li><p>Password: <code>admin</code></p>
</li>
</ul>
<p>Take a screenshot only after your demo alert appears.</p>
<p><strong>Common stuck points</strong></p>
<table>
<thead>
<tr>
<th>You think…</th>
<th>What is actually true</th>
</tr>
</thead>
<tbody><tr>
<td>“The UI shows 200+ Critical alerts, maybe I broke something”</td>
<td>No. <code>postgres-0</code> reads <code>/etc/passwd</code> on a loop and Falco flags it. Ignore those rows.</td>
</tr>
<tr>
<td>“I can't find my demo alert”</td>
<td>Search the UI with <strong>Cmd+F →</strong> <code>Shell Spawned</code>, or use the <strong>terminal grep</strong> in step 4 above. If grep shows <code>auth-service</code> + <code>id &amp;&amp; exit</code>, you passed.</td>
</tr>
<tr>
<td>“<code>make check-6</code> failed on NetworkPolicy”</td>
<td>You ran the check <strong>before §6.4</strong>. Apply netpol first, then re-run.</td>
</tr>
<tr>
<td>“§6.3 vs §6.2 — which do I run?”</td>
<td>Run <code>make demo-6</code> <strong>(§6.2)</strong> only. §6.3 is the same attacks as manual commands. Skip it if demo-6 already worked.</td>
</tr>
<tr>
<td>“What is Shell Spawned?”</td>
<td>Falco saw a <code>sh</code> <strong>process start</strong> inside <code>auth-service</code>. That's suspicious in production. In the lab, <strong>you</strong> caused it on purpose. See §6.2.</td>
</tr>
<tr>
<td>“Scenario 4 hangs or exit 137”</td>
<td>Old <code>wget</code> command + <strong>Terminating</strong> pod. Skip Scenario 4 or use the <strong>python3</strong> command in §6.4. Checkpoint + <code>make check-6</code> is enough.</td>
</tr>
</tbody></table>
<h3 id="heading-61-install-falco-and-falcosidekick-ui">6.1: Install Falco and Falcosidekick UI</h3>
<pre><code class="language-bash">bash stages/stage-6-runtime-security/scripts/install-falco.sh
</code></pre>
<p>This runs <code>helm upgrade --install</code> with <code>modern_ebpf</code>, enables Falcosidekick + Web UI, enables the <strong>k8s-metacollector</strong> (<code>collectors.kubernetes.enabled: true</code>) so custom rules can match <code>k8smeta.ns.name = clearledger</code>, loads rules from <code>infra/falco/clearledger-rules-content.yaml</code>, applies the rules ConfigMap and ingress.</p>
<p>If Falco is already installed, the script is safe to re-run (upgrade).</p>
<p><strong>Verify Falco pods:</strong></p>
<pre><code class="language-bash">kubectl get pods -n falco
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">NAME                                      READY   STATUS    RESTARTS   AGE
falco-w4fh6                               2/2     Running   0          2m
falco-falcosidekick-...                   1/1     Running   0          2m
falco-falcosidekick-ui-...                1/1     Running   0          2m
falco-falcosidekick-ui-redis-0            1/1     Running   0          2m
</code></pre>
<p>The Falco DaemonSet should show <strong>2/2 Running</strong>. Sidekick, UI, and Redis pods should each show <strong>1/1 Running</strong>. Pod name suffixes on your cluster will differ from the example.</p>
<p>Open <code>http://falco.local</code>. You'll see the Falcosidekick UI. Log in with the chart defaults:</p>
<table>
<thead>
<tr>
<th>Field</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Login</strong></td>
<td><code>admin</code></td>
</tr>
<tr>
<td><strong>Password</strong></td>
<td><code>admin</code></td>
</tr>
</tbody></table>
<p>To read the credentials from the cluster instead of trusting the lab defaults:</p>
<pre><code class="language-bash">kubectl get secret falco-falcosidekick-ui -n falco \
  -o jsonpath='{.data.FALCOSIDEKICK_UI_USER}' | base64 -d &amp;&amp; echo
# admin:admin
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/eeac67ee-182a-403d-806d-328e1fdcd8b7.png" alt="Screenshot fo Falco UI" style="display: block;" width="1293" height="1318" loading="lazy">

<h4 id="heading-falcosidekick-ui-quick-orientation">Falcosidekick UI: quick orientation</h4>
<p>After login you land on the <strong>Events</strong> tab. The table can look busy before you run any demo, which is normal.</p>
<ul>
<li><p><strong>Rule</strong>: detection name (what fired)</p>
</li>
<li><p><strong>Priority</strong>: <strong>Critical</strong> / <strong>Warning</strong> / <strong>Notice</strong> (focus on Critical and Warning for this lab)</p>
</li>
<li><p><strong>Output</strong>: pod name, file, or command details</p>
</li>
<li><p><strong>Tags</strong>: look for <code>clearledger</code> on lab alerts</p>
</li>
</ul>
<p><strong>Background noise you can ignore:</strong> Notice rows from ArgoCD. <strong>Critical</strong> <strong>Sensitive File Read</strong> rows from <code>postgres-0</code> reading <code>/etc/passwd</code> (repeats every few seconds). Your demo alert is different. See §6.2.</p>
<p><strong>Verify custom rules loaded</strong> (do this before §6.2):</p>
<pre><code class="language-bash">kubectl get pods -n falco                                    # Falco pod 2/2 Running
kubectl get configmap clearledger-falco-rules -n falco
kubectl logs -n falco -l app.kubernetes.io/name=falco -c falco --tail=200 \
  | grep 'rules.d/clearledger_rules'
</code></pre>
<p><strong>Expected:</strong> <code>clearledger_rules.yaml | schema validation: ok</code></p>
<p>An empty grep with <code>--tail=30</code> alone isn't a failure. Use <code>--tail=200</code>. If you see <code>LOAD_ERR_COMPILE_CONDITION</code>, see <code>troubleshooting.md</code>.</p>
<p>If rules didn't load, §6.2 and §6.3 will look like they passed when nothing fired.</p>
<h3 id="heading-62-guided-demo-make-demo-6">6.2: Guided Demo (<code>make demo-6</code>)</h3>
<p>Run this <strong>after</strong> §6.1 (Falco installed, rules verified, UI opens at <code>http://falco.local</code>).</p>
<pre><code class="language-bash">make demo-6
# or:
bash stages/stage-6-runtime-security/scripts/demo-falco-alerts.sh
</code></pre>
<h4 id="heading-what-the-demo-script-does">What the demo script does</h4>
<p>The demo proves Falco can detect suspicious activity inside a running container.</p>
<p>The script checks that Falco is running, opens <code>http://falco.local</code>, and waits while you log in with:</p>
<ul>
<li><p>Username: <code>admin</code></p>
</li>
<li><p>Password: <code>admin</code></p>
</li>
</ul>
<p>Then it runs this test command inside the <code>auth-service</code> container:</p>
<pre><code class="language-bash">kubectl exec -n clearledger \
  auth-service-&lt;pod-suffix&gt; \
  -c auth-service -- /bin/sh -c 'id &amp;&amp; exit'
</code></pre>
<p>The script picks the real pod name for you.</p>
<p><strong>Non-interactive</strong> (CI or no Enter prompts): <code>SKIP_PROMPT=1 make demo-6</code>.</p>
<p>This starts a shell inside the app container. That's suspicious in production because app containers should run the app, not open shells. Falco should detect it and create an alert called:</p>
<p><code>Shell Spawned in ClearLedger Container</code></p>
<p>When the script prints:</p>
<pre><code class="language-text">✓ Runtime detection confirmed
</code></pre>
<p>refresh the Falco UI.</p>
<p>Look for a <strong>Critical</strong> alert with:</p>
<ul>
<li><p>Rule: <code>Shell Spawned in ClearLedger Container</code></p>
</li>
<li><p>Pod: <code>auth-service-...</code></p>
</li>
<li><p>Command: <code>sh -c id &amp;&amp; exit</code></p>
</li>
</ul>
<p>Ignore alerts from <code>postgres-0</code>, especially <code>Sensitive File Read</code>. Those are background noise for this lab.</p>
<p>If the UI is noisy, search the page for <code>Shell Spawned</code> or check from the terminal:</p>
<pre><code class="language-bash">kubectl logs -n falco -l app.kubernetes.io/name=falco -c falco --tail=500 \
  | grep 'Shell Spawned'
</code></pre>
<p>For your screenshot, capture the <code>Shell Spawned</code> alert for <code>auth-service</code>.</p>
<h3 id="heading-63-break-it-scenarios-manual-optional">6.3: Break-it Scenarios (Manual, Optional)</h3>
<p>These are the same detections as §6.2, but you run each command yourself. Skip this section if you already completed <code>make demo-6</code>.</p>
<table>
<thead>
<tr>
<th>Rule name</th>
<th>You trigger it by…</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Shell Spawned in ClearLedger Container</strong></td>
<td>Scenario 1 — <code>kubectl exec … /bin/sh</code></td>
</tr>
<tr>
<td><strong>Sensitive File Read in ClearLedger</strong></td>
<td>Scenario 2 — <code>cat /etc/passwd</code></td>
</tr>
<tr>
<td><strong>Package Manager / Outbound connection</strong></td>
<td>Scenario 3 — <code>wget</code> or <code>curl</code></td>
</tr>
</tbody></table>
<p>After each command, refresh <code>http://falco.local</code> or use the find methods in §6.2.</p>
<h4 id="heading-scenario-1-shell-in-a-running-pod-command-injection-simulation">Scenario 1 – Shell in a running pod (command injection simulation):</h4>
<pre><code class="language-bash">kubectl exec -n clearledger \
  $(kubectl get pod -n clearledger -l app=auth-service -o name | head -1) \
  -c auth-service -- /bin/sh -c "id &amp;&amp; exit"
</code></pre>
<p><strong>Expected in Falco UI / logs</strong> (within ~10 seconds):</p>
<pre><code class="language-text">CRITICAL: Shell spawned in ClearLedger container
  user=... container=auth-service pod=auth-service-... cmd=sh -c id &amp;&amp; exit
</code></pre>
<p><strong>What this means:</strong> Stage 4 allowed the pod (it is compliant). Stage 6 detected <em>behavior inside</em> the pod: exactly what an attacker would do after command injection.</p>
<p><strong>If you see no alert:</strong> confirm the exec used <code>-c auth-service</code> (not the vault-agent sidecar), rules show <code>schema validation: ok</code>, and the pod image name contains <code>clearledger</code>.</p>
<h4 id="heading-scenario-2-read-a-sensitive-file-reconnaissance">Scenario 2 – Read a sensitive file (reconnaissance):</h4>
<pre><code class="language-bash">kubectl exec -n clearledger \
  $(kubectl get pod -n clearledger -l app=auth-service -o name | head -1) \
  -c auth-service -- cat /etc/passwd
</code></pre>
<p><strong>Expected:</strong></p>
<pre><code class="language-text">CRITICAL: Sensitive file read in ClearLedger
  file=/etc/passwd container=auth-service pod=auth-service-...
</code></pre>
<h4 id="heading-scenario-3-download-tool-at-runtime-optional">Scenario 3 – Download tool at runtime (optional):</h4>
<pre><code class="language-bash">kubectl exec -n clearledger \
  $(kubectl get pod -n clearledger -l app=auth-service -o name | head -1) \
  -c auth-service -- sh -c "wget -q ifconfig.me -O - 2&gt;/dev/null || true"
</code></pre>
<p>May fire Package manager executed and/or Unexpected outbound connection (WARNING).</p>
<p>Take screenshots of Scenarios 1 and 2: portfolio evidence for runtime detection.</p>
<h3 id="heading-64-apply-network-policies-zero-trust-segmentation">6.4: Apply Network Policies (Zero-trust Segmentation)</h3>
<p>Network policies are firewall rules between pods. Apply them after the Falco demo.</p>
<p><code>make check-6</code> checks for these policies, so run it only after this section. The <code>default-deny-all</code> policy blocks traffic by default.</p>
<p>The <code>allow-*</code> policies open only the paths ClearLedger needs to work. Falco detects suspicious behavior. Network policies limit where a pod can connect.</p>
<p><strong>Apply:</strong></p>
<pre><code class="language-bash">kubectl apply -f infra/deferred-by-stage/stage-6-runtime-security/netpol/network-policies.yaml
kubectl get networkpolicy -n clearledger
</code></pre>
<p><strong>Expected:</strong> seven policies: <code>default-deny-all</code> plus six <code>allow-*</code> (<code>auth-service</code>, <code>ledger-service</code>, <code>notification-service</code>, <code>postgres</code>, <code>redis</code>, <code>frontend</code>).</p>
<p>Verify the app still works:</p>
<pre><code class="language-bash">curl -s http://clearledger.local/auth/health | jq .
# {"status":"ok","service":"auth-service"}

curl -s http://clearledger.local/notifications/health | jq .
# {"status":"ok",...}
</code></pre>
<p><strong>Checkpoint (required)</strong>: proves netpol didn't break the real app:</p>
<pre><code class="language-bash">kubectl get networkpolicy -n clearledger
curl -s -o /dev/null -w "%{http_code}\n" http://clearledger.local/
kubectl get pods -n clearledger --field-selector=status.phase!=Running
</code></pre>
<table>
<thead>
<tr>
<th>Result</th>
<th>Meaning</th>
</tr>
</thead>
<tbody><tr>
<td>Seven policies listed</td>
<td>Netpol applied</td>
</tr>
<tr>
<td><code>200</code> from curl</td>
<td>Users can still reach the app through ingress</td>
</tr>
<tr>
<td>Third command prints <strong>nothing</strong></td>
<td>No crashed pods</td>
</tr>
</tbody></table>
<p>If auth or ledger start restarting after netpol, egress rules are too strict. See <code>troubleshooting.md</code>.</p>
<h4 id="heading-scenario-4-blocked-cross-service-traffic-optional">Scenario 4 – blocked cross-service traffic (optional)</h4>
<p>Skip if the checkpoint passed and you plan to run <code>make check-6</code>. This proves ledger can't call notification directly (no allow rule for that path). Failure to connect is success.</p>
<p><strong>Don't use the old</strong> <code>wget</code> <strong>one-liner</strong>: the ledger image has no <code>wget</code>/<code>curl</code>, and <code>head -1</code> can pick a Terminating pod (exec hangs or exit <strong>137</strong>).</p>
<pre><code class="language-bash">LEDGER_POD=$(kubectl get pods -n clearledger -l app=ledger-service --no-headers \
  | awk '$2=="2/2" &amp;&amp; $3=="Running" {print $1; exit}')

echo "Using pod: $LEDGER_POD"

kubectl exec -n clearledger "$LEDGER_POD" -c ledger-service -- python3 -c "
import urllib.request
try:
    urllib.request.urlopen('http://notification-service/', timeout=5)
    print('UNEXPECTED: connection succeeded')
except Exception as e:
    print('BLOCKED (expected):', e)
"
</code></pre>
<p><strong>Expected:</strong></p>
<pre><code class="language-text">BLOCKED (expected): &lt;urlopen error timed out&gt;
</code></pre>
<p>or <code>Connection refused</code>, <strong>not</strong> <code>UNEXPECTED: connection succeeded</code>.</p>
<h3 id="heading-66-health-check">6.6: Health Check</h3>
<p>Run this <strong>after §6.4</strong> (network policies). It confirms Falco, custom rules, and netpol are installed. It does <strong>not</strong> prove an alert fired (that is §6.2).</p>
<pre><code class="language-bash">make check-6
</code></pre>
<p><strong>What you should see:</strong></p>
<pre><code class="language-text">▶ Stage 6 — Runtime Security (Falco)
  ✓ Falco DaemonSet: 1/1 nodes
  ✓ ClearLedger custom Falco rules ConfigMap exists
  ✓ NetworkPolicy default-deny-all exists
  ✓ NetworkPolicy allow-auth-service exists
  ✓ NetworkPolicy allow-ledger-service exists
  ✓ NetworkPolicy allow-notification-service exists
  ✓ auth-service reachable after network policies
  ✓ notification-service reachable after network policies

All checks passed. Ready for the next stage.
</code></pre>
<h3 id="heading-how-stage-6-fits-the-full-stack-optional-reading">How Stage 6 fits the full stack (optional reading)</h3>
<p>Each stage guards a different point in the lifecycle. Stages 1 through 5 work before or during pod startup. Stage 6 watches what happens inside a container that is already running.</p>
<ul>
<li><p><strong>In Stage 3,</strong> CI catches bad code and images on <code>git push</code>.</p>
</li>
<li><p><strong>Stage 4,</strong> Kyverno blocks bad pods at admission.</p>
</li>
<li><p><strong>Stage 5,</strong> Vault injects secrets at startup.</p>
</li>
<li><p><strong>Stage 6,</strong> Falco watches syscalls after the pod is running (shell spawns, sensitive file reads).</p>
</li>
<li><p><strong>Stage 6,</strong> Network policies filter pod-to-pod traffic.</p>
</li>
</ul>
<p>They answer three different questions: Kyverno asks whether this pod may be created. Falco asks what the pod is doing right now. Network policies ask who the pod may talk to.</p>
<p>Falco doesn't replace CI or Kyverno. If you skip Stages 3–5, Falco can still alert, but you already shipped vulnerable code and secrets in Git.</p>
<h3 id="heading-stage-6-is-complete-proceed-to-stage-65-or-7">Stage 6 is Complete. Proceed to Stage 6.5 or 7.</h3>
<table>
<thead>
<tr>
<th>#</th>
<th>Check</th>
<th>How to verify</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>Falco running</td>
<td><code>kubectl get pods -n falco</code> — DaemonSet <code>2/2</code></td>
</tr>
<tr>
<td>2</td>
<td>Custom rules loaded</td>
<td>`kubectl logs -n falco -l app.kubernetes.io/name=falco -c falco --tail=200</td>
</tr>
<tr>
<td>3</td>
<td>Shell alert fired <strong>and you read it</strong></td>
<td><code>make demo-6</code> → Critical row with <code>cmd=sh -c id &amp;&amp; exit</code>, pod <code>auth-service-…</code> — §6.2</td>
</tr>
<tr>
<td>4</td>
<td>Network policies applied</td>
<td><code>kubectl get networkpolicy -n clearledger</code> — §6.4</td>
</tr>
<tr>
<td>5</td>
<td>App still healthy</td>
<td><code>curl</code> auth + notification health return 200</td>
</tr>
<tr>
<td>6</td>
<td>Health check</td>
<td><code>make check-6</code> green — §6.6</td>
</tr>
</tbody></table>
<p><strong>Portfolio screenshots (optional):</strong> shell-in-container alert · sensitive-file read alert in Falco UI.</p>
<p>What comes next: Stage 6 gives you Falco alerts and basic network policies. You can refine the network policies later. Stage 6.5 is optional chaos testing with Litmus, and Stage 7 adds Grafana dashboards so you can see security events over time.</p>
<h3 id="heading-what-you-learned-in-stage-6">What You Learned in Stage 6</h3>
<ul>
<li><p>What runtime security catches that CI and admission control can't: threats inside running containers</p>
</li>
<li><p>What Falco is: eBPF syscall monitoring with custom YAML rules</p>
</li>
<li><p>What network policies are: Kubernetes firewall rules between pods</p>
</li>
<li><p>How to trigger and interpret alerts: incident response skills</p>
</li>
<li><p><strong>The full stack:</strong> code scanning, admission control, secrets management, runtime detection which leads to (next) observability</p>
</li>
</ul>
<p><strong>What you can now put on your CV / say in an interview:</strong></p>
<blockquote>
<p>Deployed Falco for runtime threat detection with custom rules, and can trigger and read an alert for a shell-in-container or sensitive-file read the way an on-call engineer would.</p>
</blockquote>
<p><code>make snapshot STAGE=6 &amp;&amp; make snapshots</code>. Confirm <code>clearledger.stage6</code>. See <a href="#heading-how-to-save-your-progress">How to Save Your Progress</a>.</p>
<h2 id="heading-stage-65-chaos-engineering-optional">Stage 6.5 — Chaos Engineering (Optional)</h2>
<p><strong>Most learners skip this.</strong> If Stage 6 is done and <code>make check-6</code> passes, jump straight to <a href="#heading-stage-7-security-observability">Stage 7</a>. Nothing in Stages 7–8 requires Litmus.</p>
<p><strong>If you want chaos/resilience (~1 hour):</strong> LitmusChaos deletes one <code>auth-service</code> pod and proves <code>/auth/health</code> stays <strong>200</strong> while Kubernetes replaces it.</p>
<h3 id="heading-do-the-steps-in-this-order">Do the Steps in This Order</h3>
<table>
<thead>
<tr>
<th>Step</th>
<th>Section</th>
<th>What you do</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td><a href="#heading-650-before-you-start-auth-pods-must-be-22">§6.5.0</a></td>
<td><code>make fix-65-prereqs</code> — auth pods <strong>2/2 Ready</strong></td>
</tr>
<tr>
<td>2</td>
<td><a href="#heading-651-install-litmuschaos-operator-ui-cluster-connection">§6.5.1</a></td>
<td><code>bash ...install-litmus.sh</code> — UI shows <strong>Active 1</strong></td>
</tr>
<tr>
<td>3</td>
<td><a href="#heading-652-run-your-first-experiment-pod-delete">§6.5.2</a></td>
<td>UI: Pod-delete experiment + <code>curl</code> stays 200</td>
</tr>
<tr>
<td>4</td>
<td><a href="#heading-657-health-check">§6.5.7</a></td>
<td><code>make check-65</code>, snapshot</td>
</tr>
</tbody></table>
<p><strong>Optional:</strong> <a href="#heading-653-same-experiment-from-the-terminal-make-demo-65-optional">§6.5.3</a>: same test via <code>make demo-65</code> (terminal path) instead of the UI wizard.</p>
<h3 id="heading-650-before-you-start-auth-pods-must-be-22">6.5.0: Before You Start (Auth Pods Must be 2/2)</h3>
<p>Chaos deletes pods. If replacements fail to start, you debug CrashLoopBackOff instead of learning resilience.</p>
<pre><code class="language-bash">export GITHUB_OWNER=YOUR_GITHUB_USERNAME   # required — without this, fix-argocd breaks ArgoCD repoURL
make fix-65-prereqs
kubectl get pods -n clearledger -l app=auth-service
</code></pre>
<p><strong>Pass:</strong> two pods, both <strong>2/2 Ready</strong>. Don't install Litmus until this is true.</p>
<p><strong>If something fails:</strong></p>
<table>
<thead>
<tr>
<th>Symptom</th>
<th>Fix</th>
</tr>
</thead>
<tbody><tr>
<td>ArgoCD <strong>ComparisonError</strong> after <code>fix-65-prereqs</code></td>
<td><code>kubectl apply -f stages/stage-2-gitops/argocd/clearledger-app.yaml</code></td>
</tr>
<tr>
<td>Auth <strong>Init:0/1</strong>, Vault <code>permission denied</code></td>
<td>Re-run Stage 5 <code>setup.sh</code> + <code>seed-vault-secrets.sh</code>, delete auth/ledger pods</td>
</tr>
<tr>
<td>Auth <strong>1/2</strong> or postgres timeout</td>
<td><code>make fix-65-prereqs</code> again (adds netpol + startup probes)</td>
</tr>
</tbody></table>
<h3 id="heading-651-install-litmuschaos-operator-ui-cluster-connection">6.5.1: Install LitmusChaos (Operator, UI, Cluster Connection)</h3>
<pre><code class="language-bash">bash stages/stage-6.5-chaos-engineering/scripts/install-litmus.sh
kubectl get pods -n litmus
open http://litmus.local    # login: admin / litmus
</code></pre>
<p><strong>Pass before §6.5.2:</strong> Overview shows Infrastructures: Active 1 (not 0, not Pending).</p>
<p><strong>Verify pods:</strong></p>
<pre><code class="language-bash">kubectl get pods -n litmus
# litmus-core, chaos frontend/server, mongodb, subscriber — all Running
</code></pre>
<h4 id="heading-if-overview-shows-0-infrastructures-or-pending">If Overview shows 0 infrastructures or PENDING</h4>
<p>The UI is empty until a subscriber agent connects your cluster:</p>
<pre><code class="language-bash">export LITMUS_PASSWORD='litmus'   # only if you changed the default
bash stages/stage-6.5-chaos-engineering/scripts/connect-litmus-infra.sh
</code></pre>
<p>Hard-refresh the browser. Start at <strong><a href="http://litmus.local">http://litmus.local</a></strong> only, not old <code>/account/.../settings</code> bookmarks.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/69212567-ae17-4f8f-b60d-7c3aac1592b8.png" alt="screenshot showing litmus ui" style="display: block;" width="1255" height="627" loading="lazy">

<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/66cfe3f9-99b5-4c1b-bfed-aa90fbcca2e8.png" alt="screenshot showing litmus ui" style="display: block;" width="1267" height="951" loading="lazy">

<h4 id="heading-ui-navigation-click-order-for-652">UI navigation (click order for §6.5.2)</h4>
<ol>
<li><p><strong>Overview</strong>: Confirm <strong>Active 1</strong></p>
</li>
<li><p><strong>ChaosHubs</strong>, <strong>Pod Delete</strong>, <strong>Launch Experiment</strong></p>
</li>
<li><p><strong>Chaos Experiments</strong>: watch <strong>Running to Completed</strong></p>
</li>
</ol>
<p>Left nav: <strong>Overview</strong>, <strong>Environments</strong>, <strong>ChaosHub</strong>, <strong>Chaos Experiments</strong>. Skip <strong>Resilience Probes</strong> and deep <strong>Settings</strong> URLs for this lab.</p>
<h3 id="heading-652-run-your-first-experiment-pod-delete">6.5.2: Run Your First Experiment (Pod Delete)</h3>
<p><strong>Goal:</strong> Kill one <code>auth-service</code> pod and prove <code>/auth/health</code> stays <strong>200</strong>.</p>
<p><strong>Before you click Run in the UI</strong>, open two terminals:</p>
<pre><code class="language-bash"># Terminal A — watch pods
kubectl get pods -n clearledger -l app=auth-service -w

# Terminal B — watch health every 5 seconds
while true; do
  date +%H:%M:%S
  curl -s -o /dev/null -w "health=%{http_code}\n" http://clearledger.local/auth/health
  sleep 5
done
</code></pre>
<p><strong>In the UI (</strong><code>http://litmus.local</code><strong>):</strong> Left nav → ChaosHubs → Pod Delete card → Launch Experiment.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/ca183e75-6680-4743-9ffe-03a144e09e13.png" alt="screenshot showing litmus ui" style="display: block;" width="1267" height="951" loading="lazy">

<p><strong>Litmus UI note:</strong> ChaosCenter labels change between versions (for example, “Tune fault”, “Target selection”, “Chaos Experiment”). Match fields by <strong>concept</strong>, not exact button text. Accept wizard defaults unless the table below lists a value.</p>
<p>In the Litmus UI, open:</p>
<p><code>ChaosHubs</code> → <code>Pod Delete</code> → <code>Launch Experiment</code></p>
<p>This opens the experiment wizard. Use these values when the wizard asks for them:</p>
<ul>
<li><p>Infrastructure: <code>clearledger-cluster</code> and it must be <code>Active</code></p>
</li>
<li><p>Namespace: <code>clearledger</code></p>
</li>
<li><p>Target label: <code>app=auth-service</code></p>
</li>
<li><p>Target kind: <code>Deployment</code></p>
</li>
<li><p>Pods affected: <code>50%</code></p>
</li>
<li><p>Duration: <code>30</code> seconds</p>
</li>
<li><p>Fault/experiment name: <code>pod-delete</code></p>
</li>
</ul>
<p>Finish the wizard with Save or Create, then click Run. Don't choose <strong>Schedule</strong>.</p>
<p><strong>What success looks like:</strong></p>
<table>
<thead>
<tr>
<th>Where</th>
<th>Good sign</th>
</tr>
</thead>
<tbody><tr>
<td>Terminal A</td>
<td>One pod <strong>Terminating</strong>, then back to <strong>2/2 Ready</strong></td>
</tr>
<tr>
<td>Terminal B</td>
<td><code>health=200</code> even while one pod is down</td>
</tr>
<tr>
<td>Litmus UI</td>
<td>Experiment <strong>Running → Completed</strong></td>
</tr>
</tbody></table>
<p><strong>Prefer terminal over UI?</strong> Skip the wizard and run <a href="#heading-653-same-experiment-from-the-terminal-make-demo-65-optional">§6.5.3</a> (<code>make demo-65</code>) instead.</p>
<h3 id="heading-653-same-experiment-from-the-terminal-make-demo-65-optional">6.5.3 — Same experiment from the terminal (<code>make demo-65</code>) — optional</h3>
<p>Use this if you want to run the pod-delete test without clicking through the Litmus UI.</p>
<p>Make sure auth pods are healthy first:</p>
<pre><code class="language-bash">make fix-65-prereqs
</code></pre>
<p>Then run the demo:</p>
<pre><code class="language-bash">make demo-65
</code></pre>
<p>The script applies the <code>auth-service-pod-delete</code> ChaosEngine in the <code>litmus</code> namespace. Litmus deletes one <code>auth-service</code> pod, Kubernetes replaces it, and the script checks that <code>/auth/health</code> keeps returning <code>200</code>.</p>
<p>After it finishes, verify the result:</p>
<pre><code class="language-bash">kubectl get chaosresult -n litmus
kubectl get pods -n clearledger -l app=auth-service
</code></pre>
<p>You passed if the script ends with <code>PASS</code>, the <code>ChaosResult</code> is <code>Completed / Pass</code>, and two <code>auth-service</code> pods are running again.</p>
<p>You can also see the run in the Litmus UI: <strong>Chaos Experiments</strong> → refresh → open the latest run.</p>
<p>If new auth pods get stuck in <code>Init:0/1</code>, re-apply the Stage 6 network policies:</p>
<pre><code class="language-bash">kubectl apply -f infra/deferred-by-stage/stage-6-runtime-security/netpol/network-policies.yaml
</code></pre>
<h3 id="heading-653a-real-output-examples-verified-on-the-lab-cluster">6.5.3a: Real Output Examples (Verified on the Lab Cluster)</h3>
<p>These samples were captured from a working cluster after <code>make fix-65-prereqs</code>, <code>make connect-litmus</code>, and <code>make demo-65</code>.</p>
<h4 id="heading-make-check-65"><code>make check-65</code></h4>
<pre><code class="language-text">▶ Stage 6.5 — Chaos Engineering (LitmusChaos)
  ✓ litmus namespace exists
  ✓ litmus-admin ServiceAccount exists in litmus
  ✓ pod-delete ChaosExperiment installed in litmus
  ✓ Litmus chaos operator is running
  ✓ Litmus ChaosCenter reachable at http://litmus.local
  ✓ Litmus subscriber running (UI connected to cluster)
  ✓ auth-service healthy (baseline before chaos)
  ✓ auth-service has 2/2 Ready replicas (stable for chaos)
  ✓ allow-postgres NetworkPolicy exists (Stage 6 fix)

All checks passed. Ready for the next stage.
</code></pre>
<h4 id="heading-make-demo-65-captured-from-a-real-run-2026-06-01"><code>make demo-65</code> captured from a real run (2026-06-01)</h4>
<pre><code class="language-text">Stage 6.5 — auth-service pod-delete

Preflight: 2 auth-service pods Running

Applying ChaosEngine auth-service-pod-delete (namespace litmus)

Watching http://clearledger.local/auth/health

  10s  health=200  pods=2
  20s  health=200  pods=1
  30s  health=200  pods=1
  40s  health=200  pods=2
  50s  health=200  pods=2
  60s  health=200  pods=2

Result:
  ChaosResult: Completed / Pass
  Recovery:    2 auth-service pod(s) Running
  Health:      6/6 checks returned 200

PASS
</code></pre>
<p>If health lines show <code>000</code>, run <code>bash scripts/setup-hosts.sh</code> on your Mac and re-run. The script also tries <code>multipass exec clearledger -- curl</code> when the VM is present.</p>
<h4 id="heading-terminal-b-health-loop-expected-output">Terminal B (health loop, expected output)</h4>
<pre><code class="language-text">22:05:01
health=200
22:05:06
health=200
22:05:11
health=200
</code></pre>
<p>Pod count may show <strong>1</strong> while the replacement pod is starting, which is expected.</p>
<h4 id="heading-terminal-a-during-chaos-kubectl-get-pods-w">Terminal A during chaos (<code>kubectl get pods -w</code>)</h4>
<pre><code class="language-text">NAME                            READY   STATUS        RESTARTS   AGE
auth-service-84cc988c4d-hdb45   2/2     Running       0          67m
auth-service-84cc988c4d-b59sj   2/2     Terminating   0          15m    ← killed
auth-service-84cc988c4d-dxz9q   0/2     Pending       0          0s     ← replacement
auth-service-84cc988c4d-dxz9q   0/2     Init:0/1      0          2s
auth-service-84cc988c4d-dxz9q   2/2     Running       0          90s
</code></pre>
<h4 id="heading-after-demo-verify">After demo: verify</h4>
<pre><code class="language-bash">kubectl get chaosresult -n litmus
# auth-service-pod-delete-pod-delete   Completed   Pass

kubectl get pods -n clearledger -l app=auth-service
# auth-service-84cc988c4d-xxxxx   2/2   Running
# auth-service-84cc988c4d-yyyyy   2/2   Running

kubectl get cm subscriber-config -n litmus -o jsonpath='{.data.IS_INFRA_CONFIRMED}'
# true
</code></pre>
<h4 id="heading-subscriber-connected-infrastructure-active-in-ui">Subscriber connected (infrastructure Active in UI)</h4>
<pre><code class="language-text">kubectl logs -n litmus -l app.kubernetes.io/name=subscriber --tail=3
level=info msg="AgentID: a63c2a2c-... has been confirmed"
level=info msg="Server connection established, Listening...."
</code></pre>
<h3 id="heading-654-understand-the-yaml-files-read-before-running">6.5.4: Understand the YAML Files (Read Before Running)</h3>
<p>Each file is a <code>ChaosEngine</code>: a request to Litmus: “run experiment X against app Y for Z seconds.”</p>
<h4 id="heading-litmus-installyaml"><code>litmus-install.yaml</code></h4>
<p>This creates the <code>litmus</code> namespace only. Platform workloads live here, separate from <code>clearledger</code> app pods.</p>
<h4 id="heading-litmus-rbacyaml"><code>litmus-rbac.yaml</code></h4>
<table>
<thead>
<tr>
<th>Resource</th>
<th>What it does</th>
</tr>
</thead>
<tbody><tr>
<td><code>ServiceAccount litmus-admin</code> (namespace <code>litmus</code>)</td>
<td>Identity for Litmus runner pods</td>
</tr>
<tr>
<td><code>ClusterRoleBinding → cluster-admin</code></td>
<td>Allows deleting pods / injecting faults in <code>clearledger</code> (lab simplification. Production would use least-privilege)</td>
</tr>
</tbody></table>
<h4 id="heading-auth-service-pod-deleteyaml-experiment-1-used-by-demo"><code>auth-service-pod-delete.yaml</code> (Experiment 1: used by demo)</h4>
<pre><code class="language-yaml">metadata:
  namespace: litmus          # engine lives here (Kyverno-safe)
spec:
  appinfo:
    appns: clearledger       # target app namespace
    applabel: app=auth-service
    appkind: deployment
  experiments:
    - name: pod-delete
      spec:
        components:
          env:
            - name: PODS_AFFECTED_PERC
              value: "50"    # 50% of 2 replicas = 1 pod killed
            - name: TOTAL_CHAOS_DURATION
              value: "30"    # chaos window in seconds
</code></pre>
<p>What happens when applied:</p>
<ol>
<li><p>Operator reads <code>ChaosEngine</code> and creates <code>auth-service-pod-delete-runner</code> pod in <code>litmus</code></p>
</li>
<li><p>Runner selects one <code>auth-service</code> pod in <code>clearledger</code> and sends SIGTERM / delete</p>
</li>
<li><p>Kubernetes Deployment controller sees 1/2 replicas and schedules a replacement pod</p>
</li>
<li><p>Service routes traffic to the <strong>surviving</strong> replica during recovery</p>
</li>
<li><p><code>ChaosResult</code> CR records pass/fail from Litmus’s perspective</p>
</li>
</ol>
<h4 id="heading-ledger-service-network-latencyyaml-experiment-2-manual"><code>ledger-service-network-latency.yaml</code> (Experiment 2 — manual)</h4>
<p>Adds <strong>2000 ms</strong> network latency to <code>ledger-service</code> pods for 60 seconds. Proves timeouts return <strong>503</strong> instead of hanging the UI.</p>
<h4 id="heading-notification-service-memory-hogyaml-experiment-3-manual"><code>notification-service-memory-hog.yaml</code> (Experiment 3 — manual)</h4>
<p>Fills <strong>80%</strong> of pod memory limit for 60 seconds. Proves OOMKill + restart behavior.</p>
<p><strong>Never apply all three at once.</strong> Run one experiment, verify recovery, then the next.</p>
<h3 id="heading-655-after-the-demo-what-to-look-for-do-not-skip">6.5.5: After the Demo, What to Look For (Do Not Skip)</h3>
<p><strong>1. During chaos: availability</strong></p>
<table>
<thead>
<tr>
<th>Signal</th>
<th>Good</th>
<th>Bad</th>
</tr>
</thead>
<tbody><tr>
<td><code>curl http://clearledger.local/auth/health</code></td>
<td><strong>200</strong> while one pod is down</td>
<td>502/503/timeout</td>
</tr>
<tr>
<td><code>kubectl get pods -l app=auth-service</code></td>
<td>1 Running + 1 Init/Pending (replacement starting)</td>
<td>0 Running</td>
</tr>
</tbody></table>
<p><strong>2. After chaos: recovery</strong></p>
<table>
<thead>
<tr>
<th>Signal</th>
<th>Good</th>
<th>Bad</th>
</tr>
</thead>
<tbody><tr>
<td>Pod count</td>
<td>2/2 <strong>Ready</strong> (may take 1–2 min — Vault agent init)</td>
<td>Stuck at 1 replica</td>
</tr>
<tr>
<td>Events</td>
<td><code>Killing</code> then <code>Scheduled</code> / <code>Started</code> on new pod</td>
<td>Repeated CrashLoopBackOff</td>
</tr>
<tr>
<td>ArgoCD</td>
<td>Synced</td>
<td>—</td>
</tr>
</tbody></table>
<p><strong>3. Litmus</strong> <code>ChaosResult</code> <strong>verdict</strong></p>
<pre><code class="language-bash">kubectl get chaosresult -n litmus
</code></pre>
<p><strong>Your pass criteria:</strong></p>
<ul>
<li><p><code>/auth/health</code> returned <strong>200</strong> at least once during the chaos window</p>
</li>
<li><p>A pod was <strong>Killed</strong> (see events)</p>
</li>
<li><p>Deployment returned to <strong>2 replicas</strong></p>
</li>
</ul>
<h3 id="heading-656-manual-experiments-after-experiment-1-succeeds">6.5.6 Manual Experiments (After Experiment 1 Succeeds)</h3>
<p>Wait until both auth-service pods show <strong>2/2 Ready</strong>, then run <strong>one</strong> experiment at a time:</p>
<pre><code class="language-bash"># Experiment 2 — 2s network latency on ledger-service (60s)
kubectl delete chaosengine ledger-service-network-latency -n litmus --ignore-not-found
kubectl apply -f stages/stage-6.5-chaos-engineering/infra/chaos/ledger-service-network-latency.yaml

# Experiment 3 — memory pressure on notification-service (60s)
kubectl delete chaosengine notification-service-memory-hog -n litmus --ignore-not-found
kubectl apply -f stages/stage-6.5-chaos-engineering/infra/chaos/notification-service-memory-hog.yaml
</code></pre>
<table>
<thead>
<tr>
<th>Experiment</th>
<th>File</th>
<th>What to verify</th>
</tr>
</thead>
<tbody><tr>
<td>Pod delete</td>
<td><code>auth-service-pod-delete.yaml</code></td>
<td>Health 200 during kill, 2 replicas after</td>
</tr>
<tr>
<td>Network latency</td>
<td><code>ledger-service-network-latency.yaml</code></td>
<td>API returns 503/timeout, not infinite hang</td>
</tr>
<tr>
<td>Memory hog</td>
<td><code>notification-service-memory-hog.yaml</code></td>
<td>Pod OOMKills and restarts, Redis subscription recovers</td>
</tr>
</tbody></table>
<p>Clean up an experiment:</p>
<pre><code class="language-bash">kubectl delete chaosengine auth-service-pod-delete -n litmus
</code></pre>
<h3 id="heading-657-health-check">6.5.7: Health Check</h3>
<pre><code class="language-bash">make check-65
</code></pre>
<p><strong>Expected:</strong> see full sample in <a href="#heading-653a-real-output-examples-verified-on-the-lab-cluster">§6.5.3a</a> (<code>make check-65</code> block). Minimum:</p>
<pre><code class="language-text">▶ Stage 6.5 Chaos Engineering (LitmusChaos)
  ✓ Litmus subscriber running (UI connected to cluster)
  ✓ auth-service has 2/2 Ready replicas (stable for chaos)
  ...
All checks passed. Ready for the next stage.
</code></pre>
<h3 id="heading-stage-65-complete-checklist">Stage 6.5 Complete: Checklist</h3>
<table>
<thead>
<tr>
<th>#</th>
<th>Check</th>
<th>How to verify</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>Litmus operator running</td>
<td><code>kubectl get pods -n litmus</code> — <code>litmus-*</code> Running</td>
</tr>
<tr>
<td>2</td>
<td>Experiments installed</td>
<td><code>kubectl get chaosexperiment pod-delete -n litmus</code></td>
</tr>
<tr>
<td>3</td>
<td>Pod-delete demo run</td>
<td><code>make demo-65</code> health 200 during chaos</td>
</tr>
<tr>
<td>4</td>
<td>Recovery observed</td>
<td>2 auth-service replicas Ready. Killing/Scheduled events</td>
</tr>
<tr>
<td>5</td>
<td>Evidence saved</td>
<td>Terminal output from <code>run-chaos.sh</code> (DORA artifact)</td>
</tr>
<tr>
<td>6</td>
<td>Health check</td>
<td><code>make check-65</code> green</td>
</tr>
<tr>
<td>7</td>
<td>UI infrastructure connected</td>
<td>Overview → <strong>Active: 1</strong> (§6.5.2)</td>
</tr>
</tbody></table>
<h3 id="heading-what-you-learned-in-stage-65">What You Learned in Stage 6.5</h3>
<ul>
<li><p><strong>Detection ≠ resilience</strong>: Falco alerts don't prove HA</p>
</li>
<li><p><strong>Replicas + Services + probes</strong>: why <code>replicas: 2</code> isn't cosmetic</p>
</li>
<li><p><strong>ChaosEngine YAML</strong>: declarative failure injection as code</p>
</li>
<li><p><strong>Platform vs app namespaces</strong>: Kyverno blocks chaos runners in <code>clearledger</code>, engines run in <code>litmus</code></p>
</li>
<li><p><strong>MTTR</strong>: time from pod kill to 2/2 Ready again (Stage 7 graphs this)</p>
</li>
</ul>
<p><strong>What you can now put on your CV / say in an interview:</strong></p>
<blockquote>
<p>Ran chaos experiments with LitmusChaos (pod-delete, network latency, memory pressure) to prove the system recovers, and can distinguish detection from resilience.</p>
</blockquote>
<p><code>make snapshot STAGE=65 &amp;&amp; make snapshots</code>. Confirm <code>clearledger.stage65</code>. See <a href="#heading-how-to-save-your-progress">How to Save Your Progress</a>.</p>
<h2 id="heading-stage-7-security-observability">Stage 7 — Security Observability</h2>
<p>Security you can't measure, you can't prove.</p>
<p>The goal here is to understand how metrics, logs, and dashboards fit together. Then prove it by running commands in the terminal, watching the same events appear in Grafana, and explaining what each panel means.</p>
<p>This stage is not “install Grafana and move on.” Stage 7 isn't complete until your dashboards show real Kyverno violations and Falco alerts that you triggered in §7.4: plus portfolio screenshots (§7.6). <code>make check-7</code> only proves the stack is up; it does not prove you can detect security events.</p>
<p><strong>Before you start:</strong> <code>make check-6</code> should pass (Stage 6.5 is optional. Skip is fine). Check the VM is not overloaded: <code>multipass exec clearledger -- uptime</code>. If you ran Stage 6.5, do <a href="#heading-70-free-node-resources-scale-down-litmus">§7.0</a> first to scale Litmus down. Plan about half a day. This is the heaviest stage on a single-node VM.</p>
<p>You'll be done when §7.6 is complete: dashboards show your Kyverno denial and Falco alert, not empty panels. Then <code>make check-7</code> (§7.7), <code>make snapshot STAGE=7</code>, and <code>make snapshots</code> (confirm <code>clearledger.stage7</code>).</p>
<p><strong>Already installed?</strong> If <code>kubectl get pods -n monitoring</code> shows Grafana <strong>3/3</strong> and Loki <strong>1/1</strong>, skip §7.1. Start at §7.2 (verify the stack), then §7.4 (hands-on lab).</p>
<h3 id="heading-what-you-need-to-know-first">What You Need to Know First</h3>
<p>Up to now, each stage had its own window into the cluster. Stage 3 gave you CI scan results in GitHub Actions. Stage 4 showed Kyverno blocking a bad deploy in the terminal. Stage 6 gave you Falco alerts in its UI, and you could always run <code>kubectl logs</code> on a pod. Those views are useful, but they are scattered.</p>
<p>Stage 7 brings them together in one place: <strong>Grafana</strong>. Instead of jumping between five different tools, you open a dashboard and see whether security events, policy violations, and app health are happening over time.</p>
<h4 id="heading-the-three-tools-youre-installing">The three tools you're installing</h4>
<p><strong>Prometheus</strong> collects numbers from the cluster: things like “how many Kyverno denials in the last hour” or “how many HTTP requests per second.” It checks those numbers every 15–30 seconds and keeps a history you can graph.</p>
<p><strong>Loki</strong> collects log lines: the same kind of text you see from <code>kubectl logs</code>, but from many pods at once. Falco alerts, failed login attempts, and application errors all land here so you can search them later.</p>
<p><strong>Grafana</strong> is the web UI where charts and tables pull data from Prometheus and Loki. This is what you would show an auditor: not a one-off terminal screenshot, but proof that you can find and measure events after they happen.</p>
<p>Prometheus doesn't magically know what to collect. ServiceMonitors and PodMonitors are small config objects that point it at the right targets.<br>If Kyverno has no monitor, the Kyverno dashboard stays empty even when Kyverno is working fine. The same applies to application request rates. Those panels stay blank until §7.5, when metrics-enabled images are deployed through GitOps.</p>
<p>Logs follow a similar path. <strong>Promtail</strong> reads container logs and sends them to Loki. If Loki isn't running, Grafana log panels show “No data” even though <code>kubectl logs</code> still works on individual pods.</p>
<h4 id="heading-how-this-connects-to-what-you-already-built">How this connects to what you already built</h4>
<p>When you blocked a bad <code>kubectl apply</code> in Stage 4, Kyverno recorded that denial. In Stage 7, that shows up on the <strong>Kyverno Policy Violations</strong> dashboard (via Prometheus).</p>
<p>When you triggered a shell inside a pod in Stage 6, Falco wrote an alert. In Stage 7, that appears on the <strong>Security Event Timeline</strong> (via Loki).</p>
<p>When ClearLedger handles HTTP traffic or a failed login, those events feed the <strong>Service Health</strong> dashboards (Loki and Prometheus together).</p>
<p>Vault (Stage 5) and network policies (Stage 6) don't always have their own panel, but they still matter: fewer secrets in Git and blocked pod traffic show up indirectly in a healthier, quieter cluster.</p>
<h4 id="heading-what-youll-do-in-this-stage">What you'll do in this stage</h4>
<p>You'll run a command in the terminal (for example, a Kyverno violation or a Falco trigger) and then wait a short time while Prometheus or Loki ingests the event. Within about 15–90 seconds, the matching Grafana panel should update.</p>
<p>That's the whole point of observability for security: the terminal proves the event happened once, while the dashboard proves you can <strong>detect and measure</strong> it later without being logged into the cluster at that exact moment.</p>
<h3 id="heading-70-free-node-resources-scale-down-litmus">7.0: Free Node Resources (Scale Down Litmus)</h3>
<p>Stage 6.5 is complete. You don't need the Litmus UI, MongoDB, or chaos operator running while Prometheus, Loki, and Grafana start. They compete for the same CPUs on a single-node lab VM (6 by default, see <code>scripts/setup-cluster.sh</code>).</p>
<p>Scaling Litmus to zero frees ~500–800MB RAM and reduces CPU churn before the observability install.</p>
<pre><code class="language-bash">kubectl scale deployment,statefulset -n litmus --replicas=0 --all
kubectl get pods -n litmus
# Expected: no Running pods (Succeeded job pods from chaos experiments are OK)
multipass exec clearledger -- uptime
# Expected: load average (1m) ideally below ~8 before continuing
</code></pre>
<p>You can scale Litmus back up later if you want to re-run chaos experiments (<code>bash stages/stage-6.5-chaos-engineering/scripts/install-litmus.sh</code>). For Stages 7–7.5, keep it scaled down.</p>
<h3 id="heading-71-install-the-observability-stack">7.1: Install the Observability Stack</h3>
<p><strong>This is safe to run more than once.</strong> The script checks what's already installed. If Grafana, Prometheus, and Loki are healthy, it skips the heavy install and only updates dashboards and scrape configs. Running it again after a partial failure won't duplicate or break a working stack.</p>
<p>Only add <code>FORCE=1</code> if something is genuinely stuck, for example you edited the Helm values files and need a full reinstall, or Loki keeps crashing in a restart loop:</p>
<pre><code class="language-bash">FORCE=1 bash stages/stage-7-observability/scripts/install-observability.sh
</code></pre>
<p>On a first-time install, use the plain command in Step 1 below. Don't use <code>FORCE=1</code> unless the troubleshooting section tells you to.</p>
<p><strong>macOS, Linux, and WSL2:</strong> <code>FORCE=1 bash ...</code> works as written.</p>
<p><strong>Native Windows PowerShell</strong> doesn't use that syntax.</p>
<p>Run the lab inside <strong>WSL2 Ubuntu</strong> (recommended), or set the variable first: <code>$env:FORCE=1; bash stages/stage-7-observability/scripts/install-observability.sh</code>.</p>
<p><strong>Step 1: install</strong> (wait until the script prints <code>✓ Stage 7 installed.</code>):</p>
<pre><code class="language-bash">bash stages/stage-7-observability/scripts/install-observability.sh
</code></pre>
<h4 id="heading-if-you-see-waiting-for-falco-during-the-stage-7-install-thats-expected">If you see “Waiting for Falco” during the Stage 7 install, that's expected.</h4>
<p>You already installed Falco in Stage 6. Stage 7 is not adding a second Falco. It is making sure the existing Falco setup can feed logs and metrics into the observability stack.</p>
<p>The flow is:</p>
<ul>
<li><p>Falco still runs in the <code>falco</code> namespace.</p>
</li>
<li><p>Promtail sends Falco logs to Loki.</p>
</li>
<li><p>Grafana reads those logs from Loki.</p>
</li>
<li><p>The Security Event Timeline dashboard shows the Falco alerts.</p>
</li>
</ul>
<p>Right after install, the Grafana panels may be empty. That's normal. You need to trigger a new alert in §7.4 before the dashboard has something fresh to show.</p>
<p><strong>Step 2. Check pods</strong> (run this after Step 1 finishes):</p>
<pre><code class="language-bash">kubectl get pods -n monitoring
</code></pre>
<p>You want something like this (pod name suffixes vary):</p>
<pre><code class="language-text">NAME                                              READY   STATUS    RESTARTS   AGE
kube-prometheus-stack-grafana-....                3/3     Running   0          5m
kube-prometheus-stack-prometheus-....             2/2     Running   0          5m
loki-0                                            1/1     Running   0          5m
loki-promtail-....                                1/1     Running   0          5m
</code></pre>
<p>Grafana must show <strong>3/3</strong> Ready (not 2/3). Loki must show <strong>1/1</strong>. If pods are still <code>Pending</code> or <code>ContainerCreating</code>, wait a few minutes and run <code>kubectl get pods -n monitoring</code> again.</p>
<p><strong>Expected – Loki healthy:</strong></p>
<pre><code class="language-bash">kubectl exec -n monitoring loki-0 -- wget -qO- http://127.0.0.1:3100/ready
</code></pre>
<pre><code class="language-text">ready
</code></pre>
<p><strong>Expected – Grafana can reach Loki (same path log panels use):</strong></p>
<pre><code class="language-bash">kubectl exec -n monitoring deploy/kube-prometheus-stack-grafana -c grafana -- \
  wget -qO- --timeout=5 http://loki:3100/ready
</code></pre>
<pre><code class="language-text">ready
</code></pre>
<p><strong>Expected – Grafana UI reachable:</strong></p>
<pre><code class="language-bash">curl -sI http://grafana.local | head -n 1
</code></pre>
<pre><code class="language-text">HTTP/1.1 302 Found
</code></pre>
<p>Log into <strong><a href="http://grafana.local">http://grafana.local</a>:</strong> <code>admin</code> / <code>admin123</code></p>
<p>Empty panels right after install are <strong>normal</strong>. You haven't generated events yet. Continue to §7.2–§7.4.</p>
<p>If Helm fails: wait 30s, then <code>FORCE=1 bash stages/stage-7-observability/scripts/install-observability.sh</code>. See <code>troubleshooting.md. Stage 7</code>.</p>
<p><strong>✋ Hands-on checkpoint: confirm Loki and dashboards are ready</strong></p>
<p>Before you open Grafana, confirm the logging stack and dashboards actually installed.</p>
<p>On a single-node VM, Grafana can look fine while Loki is crash-looping or the ClearLedger dashboards never loaded. If you skip this check, you may spend the rest of Stage 7 debugging empty panels.</p>
<p><strong>Run:</strong></p>
<pre><code class="language-bash">kubectl get pods -n monitoring
kubectl get pods -n monitoring -l app.kubernetes.io/name=loki \
  -o jsonpath='{.items[*].status.containerStatuses[*].restartCount}{"\n"}'
kubectl get configmap -n monitoring -l clearledger_dashboard=1 --no-headers | wc -l
</code></pre>
<p><strong>Expected:</strong></p>
<ul>
<li><p>All monitoring pods are <code>Running</code></p>
</li>
<li><p>Grafana shows <code>3/3</code> Ready</p>
</li>
<li><p>Loki shows <code>1/1</code> Ready</p>
</li>
<li><p>Loki restart count is <code>0</code>, or low and not climbing</p>
</li>
<li><p>The dashboard count is <code>6</code></p>
</li>
</ul>
<p>If Loki keeps restarting or the dashboard count is <code>0</code>, stop here and fix the install before continuing. Empty Grafana panels usually mean Loki or the dashboards are missing, not that the security events failed.</p>
<h3 id="heading-72-verify-prometheus-loki-and-grafana-before-opening-dashboards">7.2: Verify Prometheus, Loki, and Grafana (before opening dashboards)</h3>
<p>Run these three checks so you know which layer is broken if a panel is empty.</p>
<h4 id="heading-check-1-prometheus-has-kyverno-metrics">Check 1: Prometheus has Kyverno metrics</h4>
<pre><code class="language-bash">kubectl exec -n monitoring deploy/kube-prometheus-stack-grafana -c grafana -- \
  wget -qO- 'http://kube-prometheus-stack-prometheus.monitoring:9090/api/v1/query?query=kyverno_admission_requests_total' 2&gt;/dev/null \
  | head -c 400
</code></pre>
<p>(Prometheus runs as a StatefulSet pod, not a Deployment. This query goes through Grafana to the Prometheus Service.)</p>
<p><strong>Expected:</strong> JSON with <code>"status":"success"</code> and a <code>"metric"</code> block (values may be <code>0</code> until you trigger a violation in §7.4).</p>
<p>If you see <code>"status":"success"</code> but <code>"result":[]</code>, Prometheus is up but Kyverno hasn't recorded admissions yet. That's fine before the lab.</p>
<h4 id="heading-check-2-loki-has-falco-logs">Check 2: Loki has Falco logs</h4>
<pre><code class="language-bash">kubectl exec -n monitoring loki-0 -- wget -qO- \
  'http://127.0.0.1:3100/loki/api/v1/labels' 2&gt;/dev/null | head -c 300
</code></pre>
<p><strong>Expected:</strong> JSON listing labels such as <code>"namespace"</code> (and after Falco events, you'll see <code>"falco"</code> in label values).</p>
<p>Quick log search (may return empty lines until §7.4 Exercise B):</p>
<pre><code class="language-bash">kubectl exec -n monitoring loki-0 -- wget -qO- \
  'http://127.0.0.1:3100/loki/api/v1/query?query=%7Bnamespace%3D%22falco%22%7D&amp;limit=3' 2&gt;/dev/null \
  | head -c 500
</code></pre>
<p><strong>Expected:</strong> <code>"status":"success"</code>. <code>"result":[]</code> means no Falco lines in Loki yet, not a broken Loki.</p>
<h4 id="heading-check-3-grafana-imported-clearledger-dashboards">Check 3: Grafana imported ClearLedger dashboards</h4>
<pre><code class="language-bash">curl -s -u admin:admin123 'http://grafana.local/api/search?tag=clearledger' | jq -r '.[].title'
</code></pre>
<p><strong>Expected: six titles:</strong></p>
<pre><code class="language-text">ClearLedger - Compliance Posture
ClearLedger - DORA Metrics
ClearLedger - Kubernetes Audit Log Analysis
ClearLedger - Kyverno Policy Violations
ClearLedger - Security Event Timeline
ClearLedger - Service Health + Auth Security
</code></pre>
<p>Or in the UI: go. to<strong>Dashboards</strong> then filter tag <code>clearledger</code>. You should see exactly these six (no missing names).</p>
<h3 id="heading-73-your-first-10-minutes-in-grafana">7.3: Your First 10 Minutes in Grafana</h3>
<p>This section is only a tour. You're not proving anything yet.</p>
<p><strong>Rule for all of Stage 7:</strong> an empty panel usually means no events have happened in the selected time range, not that Grafana is broken. You create the real events in §7.4.</p>
<h4 id="heading-step-1-open-grafana">Step 1: Open Grafana</h4>
<p>Go to <code>http://grafana.local</code> and log in:</p>
<ul>
<li><p>Username: <code>admin</code></p>
</li>
<li><p>Password: <code>admin123</code></p>
</li>
</ul>
<h4 id="heading-step-2-set-the-time-range">Step 2: Set the Time Range</h4>
<p>In the top-right corner, choose <strong>Last 15 minutes</strong>.</p>
<p>Keep this setting for all of Stage 7. Wider ranges like <strong>Last 24 hours</strong> can overload Loki on a single-node lab VM.</p>
<h4 id="heading-step-3-open-dashboards-one-at-a-time">Step 3: Open Dashboards One at a Time</h4>
<p>Open one dashboard, look around, then move to the next. Don't open all six at once.</p>
<ol>
<li><p><a href="http://grafana.local/d/clearledger-kyverno-violations">Kyverno Policy Violations</a>: policy blocks from Stage 4.</p>
</li>
<li><p><a href="http://grafana.local/d/clearledger-security-events">Security Event Timeline</a>: Falco alerts from Stage 6. You may see old <code>postgres</code> noise in the log table.</p>
</li>
<li><p><a href="http://grafana.local/d/clearledger-service-health">Service Health + Auth</a>: app traffic and login attempts.</p>
</li>
<li><p><a href="http://grafana.local/d/clearledger-compliance">Compliance Posture</a>: summary view for auditors. Skim it and come back after §7.4.</p>
</li>
<li><p><a href="http://grafana.local/d/clearledger-audit-logs">Audit Log Analysis</a>: empty on MicroK8s by design (audit pipeline not enabled by default).</p>
</li>
<li><p><a href="http://grafana.local/d/clearledger-dora-metrics">DORA Metrics</a>: deploy-frequency charts. Needs multiple CI runs to accumulate data. May show blank on first look. Optional.</p>
</li>
</ol>
<p>Use the short dashboard links in this guide. Avoid old bookmarked URLs with long random slugs.</p>
<p>You can also find them in Grafana: go to <strong>Dashboards</strong> then search tag <code>clearledger</code>.</p>
<h4 id="heading-step-4-how-to-read-what-you-see">Step 4: How to Read What You See</h4>
<p>Grafana panels pull data from two places:</p>
<ul>
<li><p><strong>Prometheus</strong> shows numbers over time, like Kyverno violation counts and request rates</p>
</li>
<li><p><strong>Loki</strong> shows log lines, like Falco alerts and auth-service messages</p>
</li>
</ul>
<p>A big number panel asks: did this count go above zero?</p>
<p>A line chart asks: was there a spike after I ran something?</p>
<p>A logs panel shows the actual text, like rule names, <code>CRITICAL</code>, or <code>Failed login attempt</code>.</p>
<p>If only log panels show <code>connection refused</code>, check Loki again in §7.1.</p>
<p>If number panels work but log panels fail, the problem is likely Loki, not Grafana itself.</p>
<h4 id="heading-step-5-move-on">Step 5: Move On</h4>
<p>Open dashboards 1–3, then continue to §7.4.</p>
<p>That's where you'll run commands in the terminal and watch the panels update with real security events.</p>
<h3 id="heading-74-hands-on-lab-terminal-dashboard-proof">7.4: Hands-on Lab: Terminal → Dashboard Proof</h3>
<p>This is the core learning section. For each exercise: run the command, wait, then confirm in Grafana.</p>
<p><strong>Timing:</strong> wait <strong>30–90 seconds</strong> after each command for Prometheus scrape and Loki ingestion.</p>
<h3 id="heading-two-ways-to-do-this-lab">Two Ways to Do This Lab</h3>
<h4 id="heading-option-1-follow-the-exercises-below-recommended-for-learning">Option 1: follow the exercises below (recommended for learning)</h4>
<p>Run each command yourself, then check Grafana. That's Exercise A, B, and C.</p>
<h4 id="heading-option-2-use-the-guided-script">Option 2: use the guided script</h4>
<p>The script runs the same steps and pauses so you can check Grafana between them:</p>
<pre><code class="language-bash">bash stages/stage-7-observability/scripts/generate-dashboard-data.sh
</code></pre>
<p>Or:</p>
<pre><code class="language-bash">make demo-7
</code></pre>
<p>Both commands do the same thing. The script will say things like “Press Enter after you checked the Kyverno dashboard.” Switch to Grafana, look at the panel, then come back and press Enter.</p>
<p><strong>Want it to run without pauses?</strong> (faster, less hand-holding)</p>
<pre><code class="language-bash">SKIP_PROMPT=1 make demo-7
</code></pre>
<p>Use Option 1 if you want to understand each step. Use Option 2 if you want a walkthrough. Use <code>SKIP_PROMPT=1</code> if you just want the data generated quickly.</p>
<h4 id="heading-exercise-a-kyverno-block-prometheus-kyverno-dashboard">Exercise A: Kyverno block → Prometheus → Kyverno dashboard</h4>
<p><strong>Terminal</strong>: apply a pod that violates Stage 4 policy (runs as root):</p>
<pre><code class="language-bash">cat &lt;&lt;'YAML' | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
  name: stage7-kyverno-lab
  namespace: clearledger
spec:
  containers:
    - name: test
      image: nginx:alpine
YAML
</code></pre>
<p><strong>How to know it worked:</strong></p>
<p>You're testing whether Kyverno <strong>blocks</strong> a deliberately bad pod. Success means the pod <strong>never gets created</strong>.</p>
<p><strong>Pass. You should see:</strong></p>
<ul>
<li><p>The terminal prints <code>Error from server</code> and <code>denied the request</code></p>
</li>
<li><p>The exact policy names in the error don't matter. Your output might list one rule or several (<code>disallow-root-containers</code>, <code>require-resource-limits</code>, <code>drop-all-capabilities</code>, …). More lines just means more rules failed, that's still a pass.</p>
</li>
<li><p>The pod name never shows up in the cluster:</p>
</li>
</ul>
<pre><code class="language-bash">kubectl get pods -n clearledger | grep stage7-kyverno-lab
</code></pre>
<p><strong>Expected:</strong> no output.</p>
<p><strong>If it fails: stop and fix Stage 4 first</strong></p>
<ul>
<li><p>The command ends quietly with <code>created</code> (no error)</p>
</li>
<li><p><code>kubectl get pods -n clearledger</code> shows <code>stage7-kyverno-lab</code></p>
</li>
</ul>
<p>That means Kyverno let a root pod through. Run <code>make check-4</code> before continuing Stage 7.</p>
<p><strong>Example of a passing terminal</strong> (yours may list more policies):</p>
<pre><code class="language-text">Error from server: error when creating "STDIN": admission webhook "validate.kyverno.svc" denied the request:
policy disallow-root-containers/validate-run-as-non-root fail: Running as root is not allowed
</code></pre>
<p><strong>Confirm Prometheus saw it</strong> (optional but useful if Grafana is empty):</p>
<pre><code class="language-bash">kubectl exec -n monitoring deploy/kube-prometheus-stack-grafana -c grafana -- \
  wget -qO- 'http://kube-prometheus-stack-prometheus.monitoring:9090/api/v1/query?query=kyverno_admission_requests_total{request_allowed="false"}' 2&gt;/dev/null \
  | grep -o '"value":\[[^]]*\]' | head -3
</code></pre>
<p><strong>Expected:</strong> a <code>"value"</code> entry with a recent Unix timestamp and a number <strong>greater than 0</strong> (for example <code>"value":[..., "1"]</code>). If you see this, Kyverno and Prometheus are working even when Grafana panels say <strong>No data</strong>.</p>
<p><strong>Grafana</strong>: open <a href="http://grafana.local/d/clearledger-kyverno-violations?from=now-15m&amp;to=now">Kyverno Policy Violations</a>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/7f5cf039-f2de-41d6-b71e-f5af2fc3ccab.png" alt="screenshot of Kyverno Policy Violations" style="display: block;" width="1325" height="1288" loading="lazy">

<p><strong>What you're proving:</strong> the terminal denial showed up in Grafana. You don't need every panel to light up. You need <strong>one clear sign</strong> that Kyverno blocks are being counted.</p>
<p><strong>Step 1: quick sanity check (top row, left to right)</strong></p>
<ol>
<li><p><strong>Policy Violations (time range)</strong>: big number. Pass: shows 1 or more. Fail: says No data.</p>
</li>
<li><p><strong>Violations (time range)</strong>: same idea, second counter. Pass: 1 or more.</p>
</li>
<li><p><strong>Active Kyverno Rules</strong> — usually 18. If this number shows up, Grafana can talk to Prometheus. That is good even when the first two panels are still empty.</p>
</li>
</ol>
<p><strong>Step 2: if the top two numbers work, skim the charts</strong></p>
<ul>
<li><p><strong>Violation Rate by Resource Kind</strong> (middle chart): look for a bump labeled Pod around the time you ran <code>kubectl apply</code>.</p>
</li>
<li><p><strong>Top Blocked Resource Types</strong> (bottom-left table): look for a Pod row.</p>
</li>
<li><p><strong>Violations by Namespace (trend)</strong> (bottom-right chart): look for a bump for clearledger.</p>
</li>
</ul>
<p>Charts can lag. A big number &gt; 0 in Step 1 is enough to move on. The charts are bonus proof for §7.6 screenshots.</p>
<p><strong>If the top two panels say "No data" but the terminal denial worked:</strong></p>
<p>This is common. Those panels count <strong>new</strong> denials during the time range, not the total ever recorded. One denial sometimes lands in Prometheus before Grafana's counter moves.</p>
<p>Try this:</p>
<ol>
<li><p>Run the same <code>kubectl apply</code> command again (denied again, that is expected).</p>
</li>
<li><p>Wait 60 seconds.</p>
</li>
<li><p>Click Refresh (circular arrow, top-right).</p>
</li>
</ol>
<p>After a second denial you should see 2 in the top stat panels. Screenshot that for §7.6.</p>
<p><strong>Still empty? Use Explore as backup proof:</strong></p>
<ol>
<li><p>Grafana left menu → Explore</p>
</li>
<li><p>Datasource: Prometheus</p>
</li>
<li><p>Paste: <code>sum(kyverno_admission_requests_total{request_allowed="false"})</code></p>
</li>
<li><p>Click Run query</p>
</li>
</ol>
<p><strong>Pass:</strong> the result is 1 or 2.</p>
<p>A screenshot of the terminal denial plus Explore showing a number &gt; 0 counts as portfolio proof even if the dashboard stats stay slow.</p>
<h4 id="heading-exercise-b-falco-shell-loki-security-event-timeline">Exercise B: Falco shell → Loki → Security Event Timeline</h4>
<p><strong>What you're doing (same idea as Exercise A):</strong></p>
<ul>
<li><p><strong>Exercise A:</strong> you did something bad, Kyverno blocked it, and the Grafana <strong>Kyverno</strong> dashboard updated.</p>
</li>
<li><p><strong>Exercise B:</strong> you do something suspicious inside a running pod, Falco detects it, and Grafana <strong>Security Event Timeline</strong> updates.</p>
</li>
</ul>
<p>You already did this in Stage 6 (<code>make demo-6</code>). Here you do it again and prove the alert shows up in Grafana, not only in <code>http://falco.local</code>.</p>
<p><strong>The story in one line:</strong> pretend you're an attacker who got shell access inside <code>auth-service</code>: Falco should scream, and the scream should appear on the timeline dashboard.</p>
<p><strong>Step 1: trigger the alert (terminal way)</strong></p>
<p>You're pretending an attacker got into <code>auth-service</code> and ran a quick command (<code>id</code>) to see who they're logged in as. That's suspicious. Falco is supposed to catch it.</p>
<p>The block below is three commands in order. Copy-paste the whole block:</p>
<pre><code class="language-bash">AUTH_POD=$(kubectl get pod -n clearledger -l app=auth-service \
  --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}')
echo "Using pod: $AUTH_POD"
kubectl exec -n clearledger "$AUTH_POD" -c auth-service -- /bin/sh -c 'id &amp;&amp; exit'
</code></pre>
<p>What each line does:</p>
<ol>
<li><p><strong>Line 1</strong>: finds the name of a running <code>auth-service</code> pod and saves it in <code>AUTH_POD</code>.</p>
</li>
<li><p><strong>Line 2</strong>: prints that name so you can see it worked (not empty).</p>
</li>
<li><p><strong>Line 3</strong>: runs <code>/bin/sh -c 'id &amp;&amp; exit'</code> <strong>inside</strong> that pod. This is the fake “attack.” Falco watches for shells like this.</p>
</li>
</ol>
<p><strong>Pass: you only need these two lines in the output:</strong></p>
<pre><code class="language-text">Using pod: auth-service-77b7d9cd99-xxxxx
uid=1000 gid=1000 groups=1000
</code></pre>
<ul>
<li><p>First line: a real pod name (not blank).</p>
</li>
<li><p>Second line: the <code>id</code> command ran inside the container.</p>
</li>
</ul>
<p>That's Step 1 done. The pod is still running. You didn't break anything.</p>
<p><strong>Fail: stop and fix before Step 2:</strong></p>
<ul>
<li><p><code>error: Internal error</code> or <code>container not found</code></p>
</li>
<li><p><code>Using pod:</code> with nothing after it</p>
</li>
</ul>
<p>Run <code>kubectl get pods -n clearledger -l app=auth-service</code> and retry when one pod shows <strong>Running</strong>.</p>
<p><strong>Step 2: Confirm Falco saw it (terminal, right away)</strong></p>
<p>The Falco log is one long JSON line. Don't try to read the whole thing. Run:</p>
<pre><code class="language-bash">kubectl logs -n falco -l app.kubernetes.io/name=falco --tail=50 | grep -i 'Shell spawned'
</code></pre>
<p><strong>Pass. You should see one short phrase somewhere in the line:</strong></p>
<pre><code class="language-text">Shell spawned in ClearLedger container ... pod=auth-service-... cmd=sh -c id &amp;&amp; exit
</code></pre>
<p>Or the rule name:</p>
<pre><code class="language-text">"rule":"Shell Spawned in ClearLedger Container"
</code></pre>
<p><strong>That one grep hit means Exercise B worked in the terminal.</strong> Screenshot this line for your portfolio.</p>
<p><strong>Ignore:</strong></p>
<ul>
<li><p><code>Defaulted container "falco" out of: ...</code>: normal kubectl noise</p>
</li>
<li><p>Lines about <code>postgres-0</code> and <code>/etc/passwd</code>: background noise from Stage 6, not your test</p>
</li>
<li><p>The rest of the JSON (<code>output_fields</code>, <code>k8smeta</code>, and so on). You don't need to parse it</p>
</li>
</ul>
<p><strong>If grep prints nothing:</strong> run Step 1 again, wait 5 seconds, then re-run the grep.</p>
<p><strong>Step 3: Confirm Loki stored it (wait ~60 seconds first)</strong></p>
<p>The story so far:</p>
<ul>
<li><p><strong>Step 1</strong>: you triggered the alert inside <code>auth-service</code></p>
</li>
<li><p><strong>Step 2</strong>: Falco wrote the alert to its own logs ✓</p>
</li>
</ul>
<p><strong>Step 3 asks:</strong> did that log line make it into <strong>Loki</strong> which is Grafana's log database?</p>
<p>Falco doesn't talk to Grafana directly. Promtail copies Falco's logs into Loki. That copy takes 60–90 seconds. Wait after Step 1, then run this check.</p>
<p><strong>What this command does:</strong></p>
<p>"Search Loki for Falco logs that contain <code>Shell spawned</code>, then show only lines that also mention <code>auth-service</code>."</p>
<pre><code class="language-bash">kubectl exec -n monitoring loki-0 -- wget -qO- \
  'http://127.0.0.1:3100/loki/api/v1/query?query=%7Bnamespace%3D%22falco%22%2Ccontainer%3D%22falco%22%7D%20%7C%3D%20%22Shell%20spawned%22&amp;limit=3' 2&gt;/dev/null \
  | grep -i 'auth-service'
</code></pre>
<p><strong>Pass:</strong> you see a line with both <code>auth-service</code> and <code>Shell spawned</code>. That means Loki has your alert and Grafana can show it.</p>
<p><strong>Fail (misleading pass):</strong> you grep for <code>ClearLedger</code> alone and get a hit from <code>postgres-0</code> reading <code>/etc/passwd</code>. That's background noise from Stage 6, not your shell test. Always look for <code>auth-service</code>.</p>
<p><strong>Empty output?</strong> That's OK. If Step 2 passed, <strong>continue to Step 4</strong>. Promtail may still be catching up, or the JSON is too long for this quick grep. Grafana often shows the alert even when this command prints nothing.</p>
<p><strong>Step 4: open Grafana</strong></p>
<p>Open <a href="http://grafana.local/d/clearledger-security-events?from=now-1h&amp;to=now">Security Event Timeline</a>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/c8f78ce4-8857-485b-9e4a-77c83bb95bd9.png" alt="screenshot of security timeline dashboard" style="display: block;" width="1114" height="1024" loading="lazy">

<p>This is the right dashboard. The title at the top should say <strong>ClearLedger - Security Event Timeline.</strong></p>
<p><strong>Before you look at panels:</strong></p>
<ol>
<li><p>Time range: <strong>Last 1 hour</strong> (top-right)</p>
</li>
<li><p>Auto-refresh: Off</p>
</li>
<li><p>Re-run Step 1 if your shell command was more than a few minutes ago</p>
</li>
<li><p>Wait 90 seconds, then click Refresh</p>
</li>
</ol>
<p><strong>What you'll probably see (and this is normal):</strong></p>
<ul>
<li><p><strong>CRITICAL Alerts (1h)</strong>: a big number like <strong>1.08 K</strong>. That is mostly <code>postgres-0</code> reading <code>/etc/passwd</code> on a loop (Stage 6 background noise). It does <strong>not</strong> mean you failed.</p>
</li>
<li><p><strong>Alerts by Rule Name</strong> (pie chart): dominated by <strong>Sensitive File Read in ClearLedger</strong>. Also normal.</p>
</li>
<li><p><strong>Recent CRITICAL / WARNING Events</strong>: lots of Postgres rows. Your shell alert is in there, but buried.</p>
</li>
</ul>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/577e14fa-205d-462a-beb2-7a6a08514295.png" alt="anothre screenshot showing security even timeline grafana dashboard" style="display: block;" width="1060" height="1009" loading="lazy">

<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/e3a363af-676b-47a6-a61a-fe5ee8b7c130.png" alt="e3a363af-676b-47a6-a61a-fe5ee8b7c130" style="display: block;" width="1119" height="977" loading="lazy">

<p>The top timeline (<strong>Falco Alerts by Priority - Timeline</strong>) may say <strong>No data</strong>. That's a known quirk. Don't panic, just use the log panel and browser search instead.</p>
<p><strong>How to find <em>your</em> alert (on this dashboard):</strong></p>
<ol>
<li><p>Stay on <strong>ClearLedger - Security Event Timeline</strong> — not Explore, not Tempo.</p>
</li>
<li><p>Click inside <strong>Recent CRITICAL / WARNING Events</strong> (the log list on the right).</p>
</li>
<li><p>Press <strong>Cmd+F</strong> (Mac) or <strong>Ctrl+F</strong> (Windows/Linux).</p>
</li>
<li><p>Search for <code>auth-service</code> or <code>Shell spawned</code>.</p>
</li>
</ol>
<p>If the search finds a row mentioning your pod and <strong>Shell spawned</strong>, screenshot it.</p>
<p><strong>Wrong place (common mistake):</strong> Grafana <strong>Explore</strong> with datasource <strong>Tempo</strong> showing <code>ledger-service</code> traces. That's <strong>Stage 7.5</strong> (OpenTelemetry), not Exercise B. Tempo shows request traces, not Falco security alerts.</p>
<p><strong>Pass for Exercise B (pick one):</strong></p>
<ol>
<li><p><strong>Best:</strong> Step 2 terminal grep shows <code>Shell spawned</code> <strong>and</strong> the <strong>Security Event Timeline</strong> log search finds <code>auth-service</code> / <code>Shell spawned</code> — screenshot both.</p>
</li>
<li><p><strong>Also fine:</strong> Step 2 grep screenshot <strong>plus</strong> the <strong>Security Event Timeline</strong> dashboard with <strong>CRITICAL Alerts (1h)</strong> showing a number (proves that Falco → Loki → Grafana works, even if your shell row is buried in postgres noise).</p>
</li>
<li><p><strong>Fallback (only if the dashboard search fails):</strong> Step 2 grep <strong>plus</strong> Grafana <strong>Explore</strong> with datasource <strong>Loki</strong> (not Tempo):</p>
<ul>
<li><p>Left menu, go to <strong>Explore</strong></p>
</li>
<li><p>Top-left datasource dropdown: choose <strong>Loki</strong></p>
</li>
<li><p>Query: <code>{namespace="falco", container="falco"} |= "Shell spawned"</code></p>
</li>
<li><p>Click <strong>Run query</strong></p>
</li>
<li><p>Look for a line with <code>auth-service</code></p>
</li>
</ul>
</li>
</ol>
<p>Screenshot for §7.6.</p>
<h4 id="heading-exercise-c-failed-login-loki-and-service-health">Exercise C: Failed login, Loki, and Service Health</h4>
<p><strong>The story:</strong> someone is guessing passwords on your login API.<br>You send ten bad login attempts from the terminal. <code>auth-service</code> writes <code>Failed login attempt</code> to its logs. Grafana <strong>Service Health + Auth Security</strong> should show the count go up.</p>
<p>Same pattern as A and B: terminal action, then logs, then dashboard.</p>
<p><strong>Step 1: send bad login attempts (terminal)</strong></p>
<p>Copy-paste the whole block:</p>
<pre><code class="language-bash">for i in $(seq 1 10); do
  curl -s http://clearledger.local/auth/health &gt;/dev/null
  curl -s -X POST http://clearledger.local/auth/login \
    -H 'Content-Type: application/json' \
    -d '{"email":"lab-attacker@evil.com","password":"wrong"}' &gt;/dev/null
done
echo "done"
</code></pre>
<p><strong>Pass:</strong> the only output you need is:</p>
<pre><code class="language-text">done
</code></pre>
<p>No output from the <code>curl</code> lines is normal. The loop hits <code>/auth/health</code> (keeps the app warm) and <code>/auth/login</code> with a wrong password ten times.</p>
<p><strong>Fail:</strong> <code>curl: (6) Could not resolve host</code>. Run <code>bash scripts/setup-hosts.sh</code> on your Mac. <code>curl: (7) Failed to connect</code>. Check <code>kubectl get pods -n clearledger -l app=auth-service</code>.</p>
<p><strong>Step 2: Confirm auth-service logged it</strong></p>
<pre><code class="language-bash">kubectl logs -n clearledger -l app=auth-service --tail=30 | grep -i 'Failed login' | tail -3
</code></pre>
<p><strong>Pass. You should see lines like:</strong></p>
<pre><code class="language-text">Failed login attempt for email: lab-attacker@evil.com
</code></pre>
<p>You may see several lines (one per failed attempt). One line is enough. Screenshot this for your portfolio.</p>
<p><strong>If grep prints nothing:</strong> wait 10 seconds and run again. If still empty, check the auth pod is Running: <code>kubectl get pods -n clearledger -l app=auth-service</code>.</p>
<p><strong>Step 3: open Grafana (wait ~60 seconds after Step 1)</strong></p>
<p>Open <a href="http://grafana.local/d/clearledger-service-health?from=now-1h&amp;to=now">Service Health + Auth Security</a>.</p>
<p><strong>This is the right dashboard.</strong> The title should say <strong>ClearLedger - Service Health + Auth Security</strong>.</p>
<ol>
<li><p>Time range: <strong>Last 1 hour</strong></p>
</li>
<li><p>Auto-refresh: <strong>Off</strong></p>
</li>
<li><p>Click <strong>Refresh</strong> once</p>
</li>
</ol>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/a6df00f0-3f4a-4951-ab5f-3efb498beaa1.png" alt="screenshot showing Service Health + Auth Security grafana dashboard" style="display: block;" width="1118" height="1028" loading="lazy">

<p>What to check (only these matter for Exercise C):</p>
<ol>
<li><p><strong>Failed Login Attempts (1h)</strong>: big number. <strong>Pass:</strong> <strong>&gt; 0</strong>. This is your main proof.</p>
</li>
<li><p><strong>Failed Login Log Stream</strong>: log lines in the panel. <strong>Pass:</strong> lines with <code>Failed login attempt</code> or <code>lab-attacker@evil.com</code>. Use <strong>Cmd+F</strong> inside the panel if needed.</p>
</li>
</ol>
<p>Panels you can ignore if empty:</p>
<ul>
<li><p><strong>Successful Logins</strong>: fine at <strong>0</strong> (you only sent bad passwords)</p>
</li>
<li><p><strong>Request Rate by Service</strong>: may be empty until §7.5 metrics images. Not required for Exercise C.</p>
</li>
</ul>
<p>You pass Exercise C when you have <strong>two screenshots:</strong></p>
<p><strong>Screenshot 1 (required):</strong> your Step 2 terminal output showing <code>Failed login attempt for lab-attacker@evil.com</code>. This proves the app logged the bad logins.</p>
<p><strong>Screenshot 2 (pick one of these):</strong></p>
<ul>
<li><p><strong>Option A:</strong> the <strong>Failed Login Attempts (1h)</strong> panel showing a number greater than zero (for example <strong>10</strong>). This proves Grafana counted the failures.</p>
</li>
<li><p><strong>Option B:</strong> the <strong>Failed Login Log Stream</strong> panel showing a line with <code>lab-attacker@evil.com</code>. Use this if the big number panel is still empty but the log stream has your email.</p>
</li>
</ul>
<p>You need Screenshot 1 and either Option A or Option B. That's enough for §7.6.</p>
<h4 id="heading-exercise-d-compliance-dashboard-the-auditor-summary">Exercise D: Compliance dashboard (the auditor summary)</h4>
<p><strong>What you're doing:</strong> open one dashboard that rolls up Exercises A, B, and C. This is the “show the auditor” view: admission control + runtime detection + application security in one screen.</p>
<p><strong>When:</strong> only after you finished A, B, and C.</p>
<p><strong>Step 1: open the dashboard</strong></p>
<p><a href="http://grafana.local/d/clearledger-compliance?from=now-1h&amp;to=now">Compliance Posture</a></p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/060714bc-c7c3-4d53-ad0e-d6d642970550.png" alt="screenshot of grafana Compliance Posture dashboard" style="display: block;" width="1115" height="1132" loading="lazy">

<p>Set <strong>Last 1 hour</strong>, auto-refresh <strong>Off</strong>, click <strong>Refresh</strong>.</p>
<p><strong>Step 2: Check the top row stats</strong></p>
<table>
<thead>
<tr>
<th>Stat on dashboard</th>
<th>Came from</th>
<th>Pass</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Policy Violations</strong></td>
<td>Exercise A (Kyverno)</td>
<td><strong>&gt; 0</strong></td>
</tr>
<tr>
<td><strong>Runtime Threats</strong></td>
<td>Exercise B (Falco)</td>
<td><strong>&gt; 0</strong> (postgres noise counts — that is OK)</td>
</tr>
<tr>
<td><strong>Failed Auth Attempts</strong></td>
<td>Exercise C (bad logins)</td>
<td><strong>&gt; 0</strong></td>
</tr>
</tbody></table>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/ba6d0d41-bc88-4d8b-a93b-a1b78f99f4da.png" alt="screenshot of grafana Compliance Posture dashboard" style="display: block;" width="1068" height="1058" loading="lazy">

<p>All three don't need to be huge numbers. They just need to be <strong>above zero</strong> after your tests.</p>
<p><strong>If one stat is still 0:</strong> re-run that exercise (A, B, or C), wait 90 seconds, refresh. Policy Violations may need a second Kyverno denial like Exercise A.</p>
<p>This is screenshot #3 for §7.6: the single frame that proves defense-in-depth.</p>
<p><strong>✋ Hands-on checkpoint: are you actually done with Stage 7?</strong></p>
<p>Installing Grafana isn't the goal. Detection is: you triggered real events and can see them on dashboards.</p>
<p><strong>Optional terminal check (proves Grafana is wired up):</strong></p>
<pre><code class="language-bash">curl -s -u admin:admin123 'http://grafana.local/api/search?tag=clearledger' | jq -r '.[].title'

curl -s -u admin:admin123 'http://grafana.local/api/datasources' | jq -r '.[].name'
</code></pre>
<p>First command, you should see six dashboard names:</p>
<ul>
<li><p>ClearLedger - Kyverno Policy Violations</p>
</li>
<li><p>ClearLedger - Security Event Timeline</p>
</li>
<li><p>ClearLedger - Service Health + Auth Security</p>
</li>
<li><p>ClearLedger - Compliance Posture</p>
</li>
<li><p>ClearLedger - Kubernetes Audit Log Analysis</p>
</li>
<li><p>ClearLedger - DORA Metrics</p>
</li>
</ul>
<p>Second command, you should see at least:</p>
<ul>
<li><p>Prometheus</p>
</li>
<li><p>Loki</p>
</li>
</ul>
<p><strong>What does NOT mean you're done:</strong></p>
<p><code>make check-7</code> only checks that monitoring pods are running. Green output there does <strong>not</strong> replace §7.4.</p>
<p><strong>What DOES mean you are done:</strong></p>
<p>You ran Exercises A, B, and C in §7.4 and saved the §7.6 screenshots:</p>
<ol>
<li><p>Kyverno denial (terminal + dashboard)</p>
</li>
<li><p>Falco shell alert (terminal + Security Event Timeline)</p>
</li>
<li><p>Failed logins (terminal + Service Health)</p>
</li>
<li><p>Compliance Posture summary (all three stats above zero)</p>
</li>
</ol>
<p>If you have those four screenshots, Stage 7 is complete.</p>
<h3 id="heading-75-fill-in-the-request-rate-chart-optional">7.5: Fill in the Request Rate Chart (Optional)</h3>
<p><strong>This is not required for Stage 7.</strong> Exercises A–C and §7.6 screenshots don't need this section. Skip it if you're happy moving on.</p>
<p>Also, this is not the same as Stage 7.5 (OpenTelemetry/Tempo). This subsection is only about the <strong>Request Rate by Service</strong> chart on the Service Health dashboard.</p>
<h4 id="heading-what-this-section-is-for">What this section is for:</h4>
<p>On Service Health + Auth Security, the Failed Login panels work from logs (Loki). The Request Rate by Service chart needs something different: app pods must expose a <code>/metrics</code> endpoint so Prometheus can scrape request counts.</p>
<p>The code is already in the repo (<code>app/*/prom_metrics.py</code>). Prometheus is already configured to scrape it (<code>clearledger-podmonitor.yaml</code>). The usual problem: your cluster is still running older images from before that code was in your build.</p>
<h4 id="heading-step-1-check-if-you-already-have-metrics-30-seconds">Step 1: check if you already have metrics (30 seconds)</h4>
<p>Run this first. If it passes, skip the rest of §7.5.</p>
<pre><code class="language-bash">kubectl exec -n monitoring deploy/kube-prometheus-stack-grafana -c grafana -- \
  wget -qO- 'http://kube-prometheus-stack-prometheus.monitoring:9090/api/v1/query?query=http_requests_total' 2&gt;/dev/null \
  | grep -o '"__name__":"http_requests_total"' | head -1
</code></pre>
<p><strong>Pass:</strong> prints <code>"__name__":"http_requests_total"</code>. Open Service Health, refresh, and the Request Rate by Service chart should already have lines.</p>
<p><strong>No output:</strong> continue to Step 2.</p>
<h4 id="heading-step-2-deploy-images-that-expose-metrics">Step 2: deploy images that expose <code>/metrics</code></h4>
<p>Pick one path.</p>
<p><strong>Path A: GitOps (if you have been using CI/CD since Stage 1–2)</strong></p>
<ol>
<li><p>Push a commit to <code>main</code> on your app repo.</p>
</li>
<li><p>Wait for CI to build new images and update <code>clearledger-infra</code>.</p>
</li>
<li><p>Wait for ArgoCD to show <strong>Synced</strong> and <strong>Healthy</strong> on the clearledger app.</p>
</li>
<li><p>Go to Step 3.</p>
</li>
</ol>
<p><strong>Path B: lab shortcut (faster, local only)</strong></p>
<pre><code class="language-bash">export DOCKER_USERNAME=your-dockerhub-user
bash stages/stage-7-observability/scripts/build-metrics-images.sh
</code></pre>
<p>This builds, pushes, and rolls out metrics-enabled images for all three services.</p>
<p><strong>Heads-up:</strong> ArgoCD self-heal may revert these image tags within a few minutes if <code>clearledger-infra</code> still points at older tags. That's fine for a quick lab demo. For a lasting fix, use Path A or update the infra repo (see §2 rollback notes).</p>
<h4 id="heading-step-3-verify-metrics-landed-60-seconds-after-rollout">Step 3: verify metrics landed (~60 seconds after rollout)</h4>
<pre><code class="language-bash">kubectl exec -n clearledger deploy/auth-service -c auth-service -- \
  wget -qO- http://127.0.0.1:8000/metrics 2&gt;/dev/null | head -5
</code></pre>
<p><strong>Pass:</strong> lines starting with <code># HELP</code> or <code>http_requests_total</code>.</p>
<p>Then confirm Prometheus sees them:</p>
<pre><code class="language-bash">kubectl exec -n monitoring deploy/kube-prometheus-stack-grafana -c grafana -- \
  wget -qO- 'http://kube-prometheus-stack-prometheus.monitoring:9090/api/v1/query?query=http_requests_total' 2&gt;/dev/null \
  | grep -o '"__name__":"http_requests_total"' | head -1
</code></pre>
<p><strong>Pass:</strong> <code>"__name__":"http_requests_total"</code></p>
<p>Generate a little traffic (re-run the Exercise C curl loop or hit <code>http://clearledger.local/auth/health</code> a few times), wait 60 seconds, then open <strong>Service Health + Auth Security</strong> and refresh. <strong>Request Rate by Service</strong> should show lines for <code>auth-service</code>, <code>ledger-service</code>, or <code>notification-service</code>.</p>
<h4 id="heading-when-to-stop">When to stop:</h4>
<ul>
<li><p><strong>Request Rate still empty but Failed Login panels work?</strong> You're done with Stage 7. Request Rate is a nice-to-have.</p>
</li>
<li><p><strong>Prometheus query passes but chart empty?</strong> Widen time range to <strong>Last 1 hour</strong>, generate traffic, wait 60s, refresh.</p>
</li>
</ul>
<h3 id="heading-76-wrap-up-stage-7-screenshots-done-check">7.6: Wrap up Stage 7 (Screenshots + Done Check)</h3>
<p>You're almost done. This section is just about saving proof, then moving on.</p>
<h4 id="heading-are-you-actually-finished">Are you actually finished?</h4>
<p>Opening Grafana and seeing six dashboards isn't enough. <code>make check-7</code> passing isn't enough either. That only proves pods are running.</p>
<p>You're done when you ran §7.4, waited for the panels to update, and saved three screenshots from your cluster.</p>
<p>If the panels are empty or only show old Postgres noise, go back to §7.4 first.</p>
<p><strong>Before each screenshot:</strong> set time range to <strong>Last 15 minutes</strong> (or <strong>Last 1 hour</strong> for Exercise B). Include the time picker and panel titles in the frame.</p>
<p><strong>Screenshot 1: Falco alert (Exercise B)</strong></p>
<p>Open <a href="http://grafana.local/d/clearledger-security-events">Security Event Timeline</a>.</p>
<p>Capture <strong>Recent CRITICAL / WARNING Events</strong> with a row that mentions <code>Shell spawned</code> or <code>auth-service</code>. If postgres rows bury it, use Cmd+F inside the log panel, that still counts.</p>
<p><strong>Screenshot 2: Kyverno denial (Exercise A)</strong></p>
<p>Open <a href="http://grafana.local/d/clearledger-kyverno-violations">Kyverno Policy Violations</a>.</p>
<p>Capture <strong>Policy Violations (time range)</strong> or <strong>Violations (time range)</strong> showing a number of 1 or more.</p>
<p><strong>Screenshot 3: Compliance summary (Exercise D)</strong></p>
<p>Open <a href="http://grafana.local/d/clearledger-compliance">Compliance Posture</a>.</p>
<p>Capture the top row with all three stats above zero: <strong>Policy Violations</strong>, <strong>Runtime Threats</strong>, and <strong>Failed Auth Attempts</strong>.</p>
<p><strong>Screenshot 4 (optional): Failed logins (Exercise C)</strong></p>
<p>Open <a href="http://grafana.local/d/clearledger-service-health">Service Health + Auth Security</a>.</p>
<p>Capture <strong>Failed Login Attempts (1h)</strong> above zero, or <strong>Failed Login Log Stream</strong> showing <code>lab-attacker@evil.com</code>.</p>
<p>Save files somewhere sensible, like <code>docs/evidence/stage-7-screenshot-1-falco.png</code>. Name them so you know what each proves.</p>
<p><strong>Final check:</strong> run <code>make check-7</code> (§7.7), save your VM, and you can claim Stage 7.</p>
<h3 id="heading-77-verify">7.7: Verify</h3>
<pre><code class="language-bash">make check-7
</code></pre>
<p><strong>Expected:</strong></p>
<pre><code class="language-text">▶ Stage 7 — Observability (Grafana + Prometheus + Loki)
  ✓ Prometheus is running
  ✓ Grafana reachable (http://grafana.local or in-cluster health OK)
  ✓ Loki pod is running (0 restarts)
  ✓ Loki reachable from Grafana (http://loki:3100/ready)
  ✓ ClearLedger alerting rules exist
  ✓ ClearLedger dashboards imported (6 found)
</code></pre>
<p>Warnings about Loki restarts or missing dashboards: fix with §7.1 before claiming Stage 7 complete.</p>
<p><strong>Save your VM</strong> after §7.6 and <code>make check-7</code>. See the block at the end of Stage 7 below.</p>
<h3 id="heading-78-what-broke-lab-notes-interview-talking-points">7.8: What Broke (Lab Notes + Interview Talking Points)</h3>
<p><strong>The stack in one sentence:</strong> Prometheus stores numbers (metrics), Loki stores log lines, and Grafana displays both visually. Nothing appears until something actually happens in the cluster.</p>
<h4 id="heading-what-tripped-you-up-in-the-lab">What tripped you up in the lab</h4>
<ol>
<li><p><strong>Empty dashboards right after install:</strong> Normal. Grafana doesn't create events. You trigger them in §7.4 (Kyverno denial, Falco shell, failed logins).</p>
</li>
<li><p><strong>Loki slow or refresh stuck on “Cancel”:</strong> Falco logs are huge. <strong>Last 24 hours</strong> overloads a small cluster. Use <strong>Last 1 hour</strong>, one dashboard at a time, and wait ~10 seconds.</p>
</li>
<li><p><code>make check-7</code> passed but panels still empty <em>(lab checklist only, not an interview topic)</em>: The health check confirms Prometheus/Loki/Grafana pods are up. It does <strong>not</strong> mean events exist. You still need §7.4 + §7.6 before you snapshot and move on.</p>
</li>
</ol>
<h4 id="heading-if-someone-asks-about-this-in-an-interview">If someone asks about this in an interview</h4>
<p><strong>Empty dashboards?</strong> Grafana only shows what already happened. No event in the time range means an empty panel. That's normal until you trigger something.</p>
<p><strong>Loki slow on a small cluster?</strong> Falco logs are huge. We kept time ranges short (15 minutes, not 24 hours) and opened one dashboard at a time. Same trade-off you would make in prod on limited hardware.</p>
<p><strong>How did you prove it worked?</strong> I ran the attacks myself: denied a bad pod, spawned a shell in a running container, and sent failed logins. Then I checked Grafana and screenshot the matching panels. Terminal action first, dashboard proof second.</p>
<p><strong>Short version you can say out loud:</strong></p>
<blockquote>
<p>"I connected Kyverno and Falco into Grafana. To prove it, I triggered a policy block and a runtime alert, then showed both on security dashboards. On a single-node lab, Loki got slow with wide time ranges, so we kept queries tight."</p>
</blockquote>
<p>Pipeline problems from earlier stages (Trivy, Kyverno, image tags, and so on) are in <code>docs/troubleshooting.md</code> — not something you need to rehearse for Stage 7.</p>
<h3 id="heading-79-if-panels-look-wrong-after-a-repo-update">7.9: If Panels Look Wrong After a Repo Update</h3>
<p>Re-apply dashboards, then generate real events (§7.4, not fake data):</p>
<pre><code class="language-bash">bash stages/stage-7-observability/scripts/install-observability.sh
# Then run Exercises A–C from §7.4 (Kyverno denial, Falco shell, failed logins)
</code></pre>
<p>Open Grafana at <strong>Last 1 hour</strong>, wait ~30–60s after each exercise, and refresh once. The §7.4 exercises cover expected appearance for each dashboard.</p>
<h3 id="heading-what-you-learned-in-stage-7">What You Learned in Stage 7</h3>
<ul>
<li><p><strong>Prometheus</strong> proves countable security events (Kyverno denials, HTTP rates)</p>
</li>
<li><p><strong>Loki</strong> proves forensic detail (Falco JSON, auth log lines)</p>
</li>
<li><p><strong>Grafana</strong> is the narrative layer, not a second install step after the lab</p>
</li>
<li><p>You can trace: terminal action, backend signal, panel update</p>
</li>
<li><p>ServiceMonitors / PodMonitors are what connect Stages 4–6 to charts</p>
</li>
<li><p>Empty dashboards mean “no events yet” or “wrong time range”, not “broken security”</p>
</li>
<li><p>Compliance posture is how you answer an auditor in one screen</p>
</li>
<li><p>Network policies must explicitly allow the <code>monitoring</code> namespace to reach app pods on port 8000, otherwise PodMonitor scrapes silently fail with <code>context deadline exceeded</code></p>
</li>
<li><p>Kubernetes Audit Log dashboard is empty on MicroK8s by design: the API server audit pipeline (audit-policy → file → Promtail → Loki) isn't enabled by default</p>
</li>
<li><p>Request Rate requires the full chain: app image with <code>/metrics</code>, PodMonitor, and network policy: any one missing means the panel stays empty</p>
</li>
</ul>
<p><strong>What you can now put on your CV / say in an interview:</strong></p>
<blockquote>
<p>Built security observability with Prometheus, Loki, and Grafana (dashboards correlating Kyverno violations, Falco alerts, and DORA metrics) and can prove a security event end-to-end from terminal to dashboard.</p>
</blockquote>
<h4 id="heading-stage-7-done-checklist">Stage 7 done checklist:</h4>
<ul>
<li><p><code>make check-7</code> → 6/6 ✓ (Stage 6.5 Litmus failure is expected: scaled down for memory)</p>
</li>
<li><p><code>http://grafana.local/d/clearledger-kyverno-violations</code>. Violations stat &gt; 0</p>
</li>
<li><p><code>http://grafana.local/d/clearledger-security-events</code>. CRITICAL Falco alert visible</p>
</li>
<li><p><code>http://grafana.local/d/clearledger-compliance</code>. Policy Violations + Runtime Threats + Failed Auth Attempts all &gt; 0</p>
</li>
<li><p><code>http://grafana.local/d/clearledger-service-health</code>. Failed Login Attempts &gt; 0. Request Rate &gt; 0 only if you did §7.5</p>
</li>
<li><p>Portfolio screenshots 1–3 saved</p>
</li>
</ul>
<p><code>make snapshot STAGE=7 &amp;&amp; make snapshots</code>. Confirm <code>clearledger.stage7</code>. <strong>Don't skip this</strong>. Stage 7 is heavy, and disk pressure is common. See <a href="#heading-how-to-save-your-progress">How to Save Your Progress</a>.</p>
<p>After a Mac reboot or sleep, auth/ledger pods may show <strong>Unknown</strong> or <strong>Init:0/1</strong> even though the cluster is up (see <a href="https://github.com/Osomudeya/clearledger/blob/main/docs/troubleshooting.md">troubleshooting.md) Mac reboot</a>).</p>
<h2 id="heading-stage-75-opentelemetry-optional">Stage 7.5 — OpenTelemetry (Optional)</h2>
<p><strong>You can skip this whole stage.</strong> Stage 7 (metrics + logs) is enough to finish the homelab and move to Stage 8.</p>
<p>Only do Stage 7.5 if you want distributed traces for your portfolio or interviews, and your VM has spare RAM (about 1.5 Gi free).</p>
<h3 id="heading-what-you-are-adding">What You Are Adding</h3>
<p>Stage 7 answers: <em>did something happen?</em> (Kyverno blocked a pod, Falco saw a shell, or login failed.)</p>
<p>Traces answer: <em>what steps ran on this one request, and how long did each take?</em></p>
<ul>
<li><p><strong>Metrics</strong>: how many requests, how many errors</p>
</li>
<li><p><strong>Logs</strong>: what the app printed in its log file, such as errors, warnings, login failures)</p>
</li>
<li><p><strong>Traces</strong>: ledger-service called auth-service (12ms), then Postgres (8ms)</p>
</li>
</ul>
<p>In this stage, you send one real transaction, then open that request in Grafana Explore (Tempo). You'll see each step listed with its timing: ledger-service, auth-service, Postgres.</p>
<h3 id="heading-before-you-start">Before You Start</h3>
<ol>
<li><p>Finish Stage 7: §7.4 exercises done, §7.6 screenshots saved, <code>SKIP_CHAOS_CHECK=1 make check-7</code> passes.</p>
</li>
<li><p>Check VM memory: <code>multipass exec clearledger -- free -h</code> , want about 1.5 Gi free.</p>
</li>
<li><p>If you ran Stage 6.5 Litmus, scale it down first (§7.0).</p>
</li>
</ol>
<p>You're done when you see the full request trace in Grafana Explore (Tempo datasource) and <code>make check-75</code> passes. Then <code>make snapshot STAGE=75</code>.</p>
<h3 id="heading-ignore-this-warning-in-app-logs">Ignore This Warning in App Logs</h3>
<p>Since Stage 7 you may see:</p>
<pre><code class="language-plaintext">WARNING: Transient error StatusCode.UNAVAILABLE encountered while exporting traces
</code></pre>
<p>That is harmless. The apps are already set up to send trace data, but the receiver isn't installed until §7.5.3.</p>
<p>Your apps still work fine, the trace data just gets thrown away. Installing the collector in §7.5.3 makes the warning go away.</p>
<h3 id="heading-how-tracing-is-wired">How Tracing is Wired</h3>
<ol>
<li><p>Your apps send trace data when a request runs</p>
</li>
<li><p>OTel Collector receives it (port 4317) and passes it along</p>
</li>
<li><p>Grafana Tempo stores it</p>
</li>
<li><p>Grafana Explore (Tempo selected) is where you look at one request step by step</p>
</li>
</ol>
<p>Apps talk to the collector only, not to Tempo directly. That way you can change where traces are stored later without rebuilding the apps.</p>
<h3 id="heading-751-check-memory-and-load">7.5.1: Check Memory and Load</h3>
<p>Tempo needs ~300MB. Confirm headroom before installing:</p>
<pre><code class="language-bash">multipass exec clearledger -- free -h    # want ~1.5Gi available
multipass exec clearledger -- uptime      # load should be reasonable for your CPU count
SKIP_CHAOS_CHECK=1 bash scripts/health-check.sh 7
</code></pre>
<p>If Litmus is still running from Stage 6.5, scale it down first (§7.0):</p>
<pre><code class="language-bash">kubectl get pods -n litmus --field-selector=status.phase=Running
# Expected: no resources found
</code></pre>
<h3 id="heading-752-install-grafana-tempo">7.5.2: Install Grafana Tempo</h3>
<p>Tempo is the trace storage backend. Install it into the <code>monitoring</code> namespace next to Prometheus and Loki:</p>
<pre><code class="language-bash">helm repo add grafana https://grafana.github.io/helm-charts
helm repo update

helm install tempo grafana/tempo \
  --namespace monitoring \
  --set tempo.storage.trace.backend=local \
  --set tempo.storage.trace.local.path=/var/tempo \
  --set persistence.enabled=true \
  --set persistence.size=5Gi \
  --wait
</code></pre>
<p><strong>Verify Tempo is running:</strong></p>
<pre><code class="language-bash">kubectl get pods -n monitoring -l app.kubernetes.io/name=tempo
# Expected: tempo-0   1/1   Running
</code></pre>
<pre><code class="language-bash">kubectl exec -n monitoring tempo-0 -- wget -qO- http://localhost:3200/ready
# Expected: ready
</code></pre>
<h3 id="heading-753-deploy-otel-collector-and-wire-grafana">7.5.3: Deploy OTel Collector and Wire Grafana</h3>
<p>This applies the OTel Collector (receives spans from app pods) and registers Tempo as a Grafana datasource automatically via the sidecar:</p>
<pre><code class="language-bash">kubectl apply -f stages/stage-7.5-opentelemetry/infra/otel/otel-collector.yaml
kubectl apply -f stages/stage-7.5-opentelemetry/infra/otel/grafana-datasource-tempo.yaml
</code></pre>
<p><strong>Verify the collector is running:</strong></p>
<pre><code class="language-bash">kubectl get pods -n monitoring -l app=otel-collector
# Expected: otel-collector-xxxxx   1/1   Running
</code></pre>
<p><strong>Verify the collector started (not trace receipt yet):</strong></p>
<p>Apps push spans to the collector over OTLP: the collector doesn't scrape pods. At this step you're only confirming that it's listening.</p>
<pre><code class="language-bash">kubectl logs -n monitoring deploy/otel-collector --tail=15
# Expected:
#   Starting GRPC server ... endpoint: 0.0.0.0:4317
#   Starting HTTP server ... endpoint: 0.0.0.0:4318
#   Everything is ready. Begin running and processing data.
# No crash loops or repeated errors.
</code></pre>
<p>Proof that traces are actually flowing comes later: after you generate traffic in §7.5.6, check collector logs for span export lines from the <code>debug</code> exporter, then confirm the trace in Grafana Tempo (§7.5.7).</p>
<h3 id="heading-754-enable-prometheus-remote-write-receiver">7.5.4: Enable Prometheus Remote Write Receiver</h3>
<p>The OTel Collector also forwards OTel metrics to Prometheus via remote write. Prometheus needs to accept them:</p>
<pre><code class="language-bash">helm upgrade kube-prometheus-stack prometheus-community/kube-prometheus-stack \
  --namespace monitoring \
  -f stages/stage-7-observability/infra/helm/kube-prometheus-stack-values.yaml \
  --wait
</code></pre>
<p>This applies the <code>enableRemoteWriteReceiver: true</code> setting added to the Helm values in Stage 7.5. Wait for Prometheus to restart (about 60 seconds).</p>
<h3 id="heading-755-verify-app-pods-connect-to-the-collector">7.5.5: Verify App Pods Connect to the Collector</h3>
<p>The deployments in <code>clearledger-infra</code> already have <code>OTEL_EXPORTER_OTLP_ENDPOINT</code> set. Once the collector is running, the pods auto-connect.</p>
<p>Confirm that the OTEL warnings are gone:</p>
<pre><code class="language-bash">kubectl logs -n clearledger deploy/ledger-service -c ledger-service --tail=20 2&gt;/dev/null \
  | grep -v "opentelemetry\|otlp\|Transient" | tail -10
# Expected: only INFO request logs, no WARNING: Transient error
</code></pre>
<p>If warnings persist, the network policy may not have port 4317 egress. Apply the latest policies:</p>
<pre><code class="language-bash">kubectl apply -f infra/deferred-by-stage/stage-6-runtime-security/netpol/network-policies.yaml
</code></pre>
<h3 id="heading-756-generate-a-trace">7.5.6: Generate a Trace</h3>
<p>Now create a transaction and watch it flow through the system:</p>
<pre><code class="language-bash"># Step 1: register (skip if already registered)
curl -s -X POST http://clearledger.local/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"trace-demo@clearledger.io","password":"TracePass123"}' | python3 -m json.tool

# Step 2: login and grab the token
TOKEN=$(curl -s -X POST http://clearledger.local/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"trace-demo@clearledger.io","password":"TracePass123"}' \
  | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
echo "Token acquired: ${TOKEN:0:20}..."

# Step 3: create a transaction (this is the request you will trace)
curl -s -X POST http://clearledger.local/ledger/transactions \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"amount": 5000, "direction": "credit"}' | python3 -m json.tool
</code></pre>
<p><strong>Verify the collector received spans:</strong></p>
<pre><code class="language-bash">kubectl logs -n monitoring deploy/otel-collector --tail=30 \
  | grep -iE "Traces|spans|ResourceSpans" || echo "No span lines yet — see §7.5.5 (OTEL env / netpol)"
# Expected after a successful transaction: debug exporter lines mentioning exported traces/spans
</code></pre>
<h3 id="heading-757-view-the-trace-in-grafana">7.5.7: View the Trace in Grafana</h3>
<p>Open <strong><a href="http://grafana.local">http://grafana.local</a></strong> and go to the left sidebar <strong>Explore</strong> (compass icon).</p>
<h4 id="heading-step-1-select-tempo-and-open-search">Step 1: Select Tempo and open Search</h4>
<p>At the top of the query pane:</p>
<ol>
<li><p>Datasource dropdown (orange <strong>T</strong> logo) → <strong>Tempo</strong></p>
</li>
<li><p>Query row labeled A (Tempo) → three tabs: Search | TraceQL | Service Graph</p>
</li>
<li><p>Click <strong>Search</strong>. This shows dropdown filters. <strong>TraceQL</strong> is a text box only. If you land there with nothing typed you get <code>0 series returned</code>.</p>
</li>
</ol>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/b005b7ed-ad35-4c2f-8ca4-7d685717754f.png" alt="screenshot of grafana showing tempo and ledger service" style="display: block;" width="1158" height="408" loading="lazy">

<h4 id="heading-step-2-filter-by-service">Step 2: Filter by service</h4>
<p>In the <strong>Search</strong> tab:</p>
<ul>
<li><p><strong>Service Name</strong> → type or select <code>ledger-service</code></p>
</li>
<li><p>Leave Span Name, Status, Duration, and Tags empty for now</p>
</li>
<li><p>Grafana shows the query it will run: <code>{resource.service.name="ledger-service"}</code></p>
</li>
</ul>
<p>Set the time range (top-right clock icon) to <strong>Last 15 minutes</strong> so your §7.5.6 transaction is included.</p>
<h4 id="heading-step-3-run-the-query">Step 3: Run the query</h4>
<p>Grafana Explore has <strong>no “Run query” button</strong>: results appear automatically after selecting a service. If the table stays empty, use the <strong>blue refresh button</strong> top-right of the pane.</p>
<h4 id="heading-step-4-open-the-trace-waterfall">Step 4: Open the trace waterfall</h4>
<p>Below the query editor, find <strong>Table - Traces</strong>. You should see at least one row like:</p>
<table>
<thead>
<tr>
<th>Column</th>
<th>Example</th>
</tr>
</thead>
<tbody><tr>
<td>Trace ID</td>
<td><code>5730edf3…</code> (blue link)</td>
</tr>
<tr>
<td>Start time</td>
<td>when you ran the <code>curl</code></td>
</tr>
<tr>
<td>Service</td>
<td><code>ledger-service</code></td>
</tr>
<tr>
<td>Name</td>
<td><code>POST /transactions</code></td>
</tr>
<tr>
<td>Duration</td>
<td>~200ms (yours may differ)</td>
</tr>
</tbody></table>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/ac4aad25-4ca4-4ffc-9fb6-25c30f0214bd.png" alt="screenshot of grafana showing tempo and ledger service and query result" style="display: block;" width="1179" height="1055" loading="lazy">

<p><strong>Click the Trace ID link.</strong> The right panel opens the trace detail view.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/d3c15dc0-e4b3-49e4-93e6-28b49feab6eb.png" alt="screenshot of grafana showing tempo and ledger service and query results" style="display: block;" width="1226" height="1289" loading="lazy">

<h4 id="heading-what-the-trace-detail-view-shows">What the trace detail view shows</h4>
<p>Header: <code>ledger-service: POST /transactions</code></p>
<ul>
<li><p><strong>Trace ID</strong>: unique ID for this request</p>
</li>
<li><p><strong>Duration</strong>: total end-to-end time</p>
</li>
<li><p><strong>Services</strong>: <code>2</code> (<code>ledger-service</code> and <code>auth-service</code> for a normal transaction)</p>
</li>
</ul>
<p>Expand spans in the timeline:</p>
<pre><code class="language-plaintext">ledger-service   POST /transactions          (~total duration)
  ├── auth-service   GET /verify             ← JWT check over HTTP
  ├── ledger-service INSERT / sqlalchemy    ← Postgres write
  └── (optional) redis PUBLISH              ← only if amount ≥ notification threshold
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/0b69babc-90c6-4cdc-a0bf-bad012854a36.jpg" alt="trace transaction flow" style="display: block;" width="1536" height="957" loading="lazy">

<p><strong>Reading the trace detail screen:</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/3a72b4a4-2243-403d-9f03-ebc3b22e5337.png" alt="Tempo trace detail: ledger-service transaction with auth-service verify step." style="display: block;" width="1226" height="1289" loading="lazy">

<p>Each row is one step in the request (Grafana calls it a <em>span</em>). The colored bar on the right shows <strong>how long that step took</strong>. That's the <em>span bar</em>. A longer bar = more time spent on that step.</p>
<p>Click a row or its bar to open the details panel on the right. You'll see two kinds of metadata:</p>
<ul>
<li><p><strong>Span attributes</strong>: what happened in <em>this step</em>.<br>Examples: HTTP method (<code>POST</code>, <code>GET</code>), status code (<code>200</code>), or SQL text on a database step. In your trace you might see <code>asgi.event.type: http.request</code> on the FastAPI receive step.</p>
</li>
<li><p><strong>Resource attributes</strong>: <em>where</em> the step ran.<br>Examples: <code>service.name: ledger-service</code>, <code>k8s.cluster.name: clearledger</code>, <code>deployment.environment: production</code>.</p>
</li>
</ul>
<p>Quick mental model: span attributes = what the step did. Resource attributes = which service produced it.</p>
<p><strong>Connecting traces to logs:</strong> once you have a step selected, the Logs tab will take you straight to the matching Loki log lines for that pod at the same moment in time.</p>
<p><strong>Screenshot this trace detail view</strong>: portfolio proof for Stage 7.5.</p>
<h4 id="heading-traceql-alternative">TraceQL alternative</h4>
<p>If you prefer the text box, Switch to the <strong>TraceQL</strong> tab, and paste:</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/679d687d-1a2e-474b-8f7e-672f3ab2eb2b.png" alt="screenshot showing direction for where traceql button is" style="display: block;" width="1157" height="263" loading="lazy">

<pre><code class="language-traceql">{ resource.service.name = "ledger-service" }
</code></pre>
<h4 id="heading-if-the-table-is-empty">If the table is empty</h4>
<p><strong>If TraceQL says</strong> <code>0 series returned</code>: Use the <strong>Search</strong> tab instead, or paste the TraceQL query from above into the TraceQL tab.</p>
<p><strong>If search tab has no rows:</strong> Widen the time range to <strong>Last 15 minutes</strong>, re-run the transaction curl from §7.5.6, wait a few seconds, and refresh.</p>
<p><strong>If grafana can't connect to Tempo:</strong> The datasource URL needs port <strong>3200</strong>. Re-apply the datasource and restart Grafana:</p>
<pre><code class="language-bash">kubectl apply -f stages/stage-7.5-opentelemetry/infra/otel/grafana-datasource-tempo.yaml
kubectl rollout restart deployment/kube-prometheus-stack-grafana -n monitoring
</code></pre>
<p><strong>If collector logs show no trace data:</strong> Work through §7.5.5: usually the OTEL environment variables or network policy blocking port 4317.</p>
<h3 id="heading-757b-understand-when-a-trace-happens">7.5.7b: Understand When a Trace Happens</h3>
<p>You ran one curl command in §7.5.6. Grafana shows every place that single request traveled.</p>
<p>Think of it like tracking a package:</p>
<ol>
<li><p><strong>You</strong> sent <code>POST /transactions</code> to <strong>ledger-service</strong></p>
</li>
<li><p><strong>ledger-service</strong> asked <strong>auth-service</strong>: "is this user logged in?"</p>
</li>
<li><p><strong>ledger-service</strong> saved the row to the <strong>database</strong></p>
</li>
<li><p><strong>redis</strong> only runs if the amount is <strong>big</strong> (10,000 or more)</p>
</li>
</ol>
<p>Each of those is a row you see in the Tempo detail screen. You're not looking at four separate requests. It's <strong>one</strong> request with multiple stops.</p>
<p><strong>Why do I see both ledger-service and auth-service?</strong></p>
<p>Because ledger had to call auth before it could save the transaction. Grafana groups those stops into one trip so you can see the full path, not just the first hop.</p>
<p><strong>Why did my demo have no Redis row?</strong></p>
<p>You used <code>"amount": 5000</code>. The app only talks to Redis when the amount is 10,000 or higher. So seeing ledger + auth + database but no Redis is correct.</p>
<p>Want to see Redis? Run §7.5.6 again with <code>"amount": 15000</code> and search Tempo again.</p>
<p><strong>Optional: connect it to the code</strong></p>
<p>Open <code>app/ledger-service/main.py</code>, find <code>create_transaction</code>, and read top to bottom. The Tempo rows follow that function in order: check the user, save to the database, and maybe notify Redis.</p>
<p><strong>Optional: same request, three tools</strong></p>
<p>At the time you ran the curl:</p>
<ul>
<li><p><strong>Tempo</strong> (this stage): which services ran and how long each took</p>
</li>
<li><p><strong>Loki</strong> (Stage 7): what the apps wrote in their log files</p>
</li>
<li><p><strong>Prometheus</strong> (Stage 7): how many requests happened around that time</p>
</li>
</ul>
<p>Same moment, three different views. You already used Loki and Prometheus in Stage 7.</p>
<h3 id="heading-758-verify">7.5.8: Verify</h3>
<pre><code class="language-bash">make check-75
</code></pre>
<p>Expected output:</p>
<pre><code class="language-text">▶ Stage 7.5 — OpenTelemetry (Distributed Tracing)
  ✓ OTel Collector is running (1 replica(s))
  ✓ Grafana Tempo datasource ConfigMap exists
  ✓ Tempo is running
  ✓ auth-service has OTEL_EXPORTER_OTLP_ENDPOINT set
</code></pre>
<p><strong>If you see a warning instead:</strong></p>
<pre><code class="language-text">⚠ OTel env vars not found on auth-service, redeploy with updated manifests
</code></pre>
<p><code>check-75</code> looks for <code>OTEL_EXPORTER_OTLP_ENDPOINT</code> in the deployment manifest. Older Stage 5 manifests may not list it even though tracing works: the Python apps default to <code>http://otel-collector.monitoring.svc.cluster.local:4317</code> when the env var is missing.</p>
<p>You can proceed if collector logs show spans and Tempo shows your trace. To clear the warning, apply only the app deployments (not the whole kustomize tree: Kyverno may block redis/postgres patches):</p>
<pre><code class="language-bash">kubectl apply -f infra/manifests/auth-service/deployment.yaml
kubectl apply -f infra/manifests/ledger-service/deployment.yaml
kubectl rollout restart deployment/auth-service deployment/ledger-service -n clearledger
make check-75
</code></pre>
<p><strong>Save your VM</strong> after <code>make check-75</code>. See the block at the end of Stage 7.5 below.</p>
<h3 id="heading-what-you-learned">What You Learned</h3>
<p>Stage 7 gave you metrics (how busy?) and logs (what was printed?). Stage 7.5 adds traces (for one slow request, which step took the time?).</p>
<p>In the lab you proved it with one <code>POST /transactions</code> curl. In production the idea is the same: a user hits an API, the request crosses multiple services, and you need to see that full path in one place.</p>
<h4 id="heading-if-someone-asks-in-an-interview">If someone asks in an interview:</h4>
<p><strong>Why traces at all?</strong> Metrics might tell you p99 latency doubled. Logs might show an error on one pod. Traces tell you <em>which downstream call</em> in the chain caused the delay, auth, database, cache, or third-party API, without guessing.</p>
<p><strong>How did you implement it?</strong> We instrumented the services with OpenTelemetry, sent telemetry to a collector, and stored traces in Grafana Tempo. Apps talk to the collector, not directly to the backend, so we can change storage later without redeploying every service.</p>
<p><strong>What would you do in an incident?</strong> Find a slow or failing trace ID (from logs, metrics, or an alert), open it in Tempo, walk the call chain service by service, see where time stacked up, then jump to logs for that service at the same timestamp. That's faster than tailing logs on five pods and hoping they line up.</p>
<p><strong>Short version you can say out loud:</strong></p>
<blockquote>
<p>"We use the three pillars together: Prometheus for rates and errors, Loki for log detail, and Tempo for request-level debugging across microservices. When latency spikes, I start from a trace, identify the slow hop, often a database or downstream API, and correlate back to logs and metrics for that service."</p>
</blockquote>
<p><code>make check-75 &amp;&amp; make snapshot STAGE=75 &amp;&amp; make snapshots</code>. Confirm <code>clearledger.stage75</code>. See <a href="#heading-how-to-save-your-progress">How to Save Your Progress</a>.</p>
<h2 id="heading-stage-8-aws-migration">Stage 8 — AWS Migration</h2>
<p>Your goal here is to run the same ClearLedger app on AWS instead of your laptop VM.</p>
<p>You're not rewriting the application. Stages 0–7 built containers on Kubernetes with GitOps, Kyverno, secrets, and observability. Stage 8 changes where it runs. You keep the same images, the same ArgoCD workflow, and the same security policies. Only the cloud services underneath change (MicroK8s → EKS, Vault → Secrets Manager, and so on).</p>
<ul>
<li><p><strong>Homelab:</strong> MicroK8s, Postgres in a pod, dev Vault, Docker Hub, <code>clearledger.local</code></p>
</li>
<li><p><strong>AWS:</strong> EKS, RDS, Secrets Manager, ECR, ALB hostname</p>
</li>
</ul>
<p><strong>Am I ready for Stage 8?</strong></p>
<ul>
<li><p>Homelab complete through Stage 7 (Stage 7.5 optional)</p>
</li>
<li><p>make check-7 passes (and make check-75 if you did traces)</p>
</li>
<li><p>AWS account with billing alerts enabled. make aws-up creates billable resources</p>
</li>
<li><p>Skim §8.2 so you know what make aws-up does (even if you use the quick path)</p>
</li>
</ul>
<p><strong>Done when</strong> the app is reachable on the AWS ALB, ArgoCD syncing, and you run <code>make aws-down</code> when finished to stop charges.</p>
<h3 id="heading-what-make-aws-up-gives-you">What <code>make aws-up</code> Gives You</h3>
<p>This is a <strong>demo stack</strong>: production-<em>shaped</em>, but not production-<em>ready</em>. It has HTTP only (no TLS cert).</p>
<p>Stage 7 observability is installed automatically. CI still runs Gitleaks, Semgrep, Checkov, Trivy, and Cosign.</p>
<p>For real production, you would add HTTPS (see <a href="https://github.com/Osomudeya/clearledger/blob/main/stages/stage-8-aws-migration/manifests/ingress-aws-https.example.yaml"><code>ingress-aws-https.example.yaml</code></a>), staging before promote, and alert routing. Those are documented but not applied by the spinup script.</p>
<p><strong>GitOps rule:</strong> after bootstrap, don't <code>kubectl apply</code> app Deployments by hand. ArgoCD owns the cluster (Stage 2). Push manifest changes to Git and let ArgoCD sync.</p>
<h3 id="heading-secrets-on-aws">Secrets on AWS</h3>
<p>On the homelab, Vault wrote secret files into the pod. On AWS, secrets live in <strong>AWS Secrets Manager</strong> (created by Terraform). Your app still needs them as environment variables like <code>DATABASE_URL</code>.</p>
<p><strong>ESO (default in this lab)</strong>: the simple mental model:</p>
<ol>
<li><p>Terraform stores the real password in AWS Secrets Manager (for example <code>clearledger/auth-service</code>)</p>
</li>
<li><p>External Secrets Operator (ESO) watches that AWS secret</p>
</li>
<li><p>ESO copies it into a normal Kubernetes Secret inside the cluster (for example <code>auth-service-secret</code>)</p>
</li>
<li><p>Your deployment reads <code>DATABASE_URL</code> from that Kubernetes Secret, same as Stage 0, but the values come from AWS instead of a YAML file in Git</p>
</li>
</ol>
<p>You never put passwords in Git. ESO keeps the Kubernetes Secret in sync with Secrets Manager.</p>
<p><strong>CSI (optional, §8.5 exercise)</strong>: same AWS secrets but different delivery: mounted as <strong>files</strong> at <code>/mnt/secrets/*</code> instead of env vars. This is closer to how Vault worked on the homelab.</p>
<p><strong>IRSA</strong>: how ESO is allowed to read Secrets Manager without storing AWS access keys in the cluster. AWS trusts a Kubernetes service account instead.</p>
<p>IRSA lets AWS trust a Kubernetes ServiceAccount, no <code>AWS_ACCESS_KEY_ID</code> in Git or in the cluster.</p>
<p>Details here: <a href="https://github.com/Osomudeya/clearledger/tree/main/stages/stage-8-aws-migration/docs"><code>stages/stage-8-aws-migration/docs/secrets-patterns.md</code></a>.</p>
<h3 id="heading-81-two-ways-through-stage-8">8.1: Two Ways Through Stage 8</h3>
<p><strong>Quick path (~45–60 min):</strong> edit <code>terraform/secrets.tf</code> (replace <code>CHANGE_ME_BEFORE_APPLY</code>), then:</p>
<pre><code class="language-bash">make aws-up    # runs stages/stage-8-aws-migration/scripts/aws-spinup.sh
make aws-down  # destroys billable resources when you are done
</code></pre>
<p>Read §8.2 afterward so you know what ran.</p>
<p><strong>Manual path (§8.3):</strong> run Terraform, ECR push, ArgoCD, Kyverno, ESO, and deploy yourself. Use this when learning, interviewing, or debugging a failed spinup.</p>
<p>Don't skip §8.2–§8.5 if you only ran <code>make aws-up</code>. Otherwise you won't know what Terraform, ESO, or ArgoCD each did.</p>
<p>Before your first Stage 8 push, read <a href="#heading-ci-routing-stages-17-vs-stage-8">§8: CI routing and <code>CLEARLEDGER_CI_TARGET</code></a> and set <code>CLEARLEDGER_CI_TARGET=aws</code> only after Terraform succeeds, not while you are still on Stages 1–7.</p>
<h3 id="heading-82-what-make-aws-up-runs">8.2: What <code>make aws-up</code> Runs</h3>
<p>The spinup script runs 15 steps in order:</p>
<p><strong>Setup (1–6)</strong>: Check tools and AWS login; <code>terraform apply</code> (VPC, EKS, RDS, ECR, Secrets Manager, GuardDuty, CloudTrail, IAM), confirm security services, build and push images to ECR, patch <code>manifests/kustomization.yaml</code> with your registry and git SHA, and configure <code>kubectl</code> for EKS.</p>
<p><strong>Platform (7–12)</strong>: install ArgoCD; Kyverno + cluster policies, Falco, External Secrets Operator + IRSA service accounts, CSI secrets driver, and Stage 7 observability stack.</p>
<p><strong>Deploy (13–15)</strong>: ArgoCD app <code>clearledger-aws</code> syncs <code>stages/stage-8-aws-migration/manifests/</code>, wait for ALB hostname, and print URL and tear-down reminder.</p>
<p>After the script finishes, open the printed <code>http://&lt;alb-dns&gt;/</code> in your browser (ClearLedger login UI), or follow <a href="#heading-when-to-open-what-checkpoint-map">§8.3. When to open what</a> for Argo CD and Grafana port-forwards.</p>
<p>Default app deploy uses ESO for secrets. CSI is also installed so you can try file mounts in §8.5 without extra setup.</p>
<p><strong>Terraform layout</strong>: there's no <code>terraform.tf</code> file. The <code>terraform {}</code> block (version, providers, optional S3 backend) is at the top of <code>main.tf</code>. Resources are split by topic: <code>vpc.tf</code>, <code>eks.tf</code>, <code>rds.tf</code>, <code>ecr.tf</code>, <code>alb.tf</code>, <code>iam.tf</code>, <code>secrets.tf</code>, <code>security.tf</code>.</p>
<p>Run all commands from <code>stages/stage-8-aws-migration/terraform/</code>.</p>
<h3 id="heading-83-manual-walkthrough">8.3: Manual Walkthrough</h3>
<p>Go to <strong>Before you start</strong> in this section and run the manual steps from <strong>Step A</strong> yourself at least once instead of <code>make aws-up</code>. Paths are from the repo root.</p>
<p>Commands install things, while UIs prove they work. Homelab Stages 2 and 7 already taught you to open Argo CD and Grafana in a browser. Stage 8 is the same idea.</p>
<p>But on AWS there's no <code>clearledger.local</code> or <code>grafana.local</code> in <code>/etc/hosts</code>. You use port-forward for control-plane UIs and the public ALB hostname for the app.</p>
<h4 id="heading-when-to-open-what-checkpoint-map">When to open what (checkpoint map)</h4>
<p><code>make aws-up</code> runs fifteen steps. You don't need every UI open at once, just know when to look and what success looks like as the script moves along.</p>
<p>First, Terraform builds the AWS foundation. When step 2 finishes, open the <strong>AWS Console</strong> and confirm the cluster, registry, and database exist before any pods run: EKS <code>clearledger</code> is <strong>Active</strong>, ECR has four repos including <code>frontend</code> (empty is fine for now), and RDS <code>clearledger-postgres</code> is <strong>Available</strong>.</p>
<p>See <a href="#heading-aws-console-after-step-2">AWS Console (after step 2)</a> for the walkthrough.</p>
<p>Next come container images. After step 4, or after CI — AWS (ECR + OIDC) goes green in GitHub Actions, check ECR: each repo should list your git SHA tag. That's what ArgoCD will pull when the app deploys.</p>
<p>Around step 7 the script installs Argo CD. Port-forward to the UI and confirm the login page loads. You won't see the app yet. You're only checking that GitOps is reachable. Details: <a href="#heading-step-13-watch-argocd-sync-ui-cli">Argo CD UI</a>.</p>
<p>Step 12 adds observability. Port-forward to Grafana, log in, and confirm the six ClearLedger dashboards are listed. Panels can stay empty until you generate events. This is the same as Stage 7 on the homelab.</p>
<p>Step 13 applies the <code>clearledger-aws</code> app. Go back to Argo CD → <strong>Applications</strong> → <code>clearledger-aws</code>. You want Synced, Healthy, and running pods for auth, ledger, and notification.</p>
<p>Step 14 exposes the app on a public URL. Open <code>http://&lt;alb-dns&gt;/</code> in your browser: you should see the same ClearLedger login UI as homelab <code>clearledger.local</code>, served from the ALB with no <code>/etc/hosts</code> entry.<br>Use <code>/auth/health</code> and the other health URLs when you want a quick API check from the terminal.</p>
<p>See <a href="#heading-step-15-open-the-app-in-your-browser">ALB — first time the app is public</a>.</p>
<p>If you want extra confirmation, the optional check is <strong>EC2 → Load Balancers →</strong> <code>clearledger</code>: status <strong>Active</strong>, with healthy targets for frontend and the API services.</p>
<p>On AWS the app is four services behind one ALB: the frontend at <code>/</code> (login, dashboard, transactions) and the three APIs at <code>/auth</code>, <code>/ledger</code>, and <code>/notifications</code>.</p>
<p>Your portfolio screenshot for Stage 8 is the ALB URL showing the UI, like <code>http://clearledger-xxxxxxxxxx.eu-west-1.elb.amazonaws.com</code> with the ClearLedger login or dashboard visible.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/411a7ae0-8e7e-4a19-9267-e207f78ece93.png" alt="screenshot of clearledger ui with ALB Url" style="display: block;" width="1348" height="364" loading="lazy">

<p>For Argo CD and Grafana, keep a dedicated terminal running <code>kubectl port-forward</code> while the browser tab is open. <code>Ctrl+C</code> closes the tunnel.</p>
<h4 id="heading-before-you-start">Before you start</h4>
<p><strong>Step A: set real passwords in</strong> <code>secrets.tf</code></p>
<p>Open <code>stages/stage-8-aws-migration/terraform/secrets.tf</code> and search for the literal text <code>CHANGE_ME_BEFORE_APPLY</code>. It appears four times in the file (Postgres password, JWT secret, and two database URLs). Replace every occurrence:</p>
<ul>
<li><p><strong>Postgres password</strong>: pick a strong password (same value in all three places that reference it)</p>
</li>
<li><p><strong>JWT secret</strong>: run <code>openssl rand -base64 64</code> and paste the output</p>
</li>
</ul>
<p><code>make aws-up</code> will <strong>refuse to run</strong> if any <code>CHANGE_ME_BEFORE_APPLY</code> text is still in that file.</p>
<p><strong>Step B: terminal checks</strong></p>
<pre><code class="language-bash">aws sts get-caller-identity
terraform --version

# REQUIRED before first terraform apply; GitHub Actions OIDC (ci-aws.yaml) reads this at apply time:
cp stages/stage-8-aws-migration/terraform/terraform.tfvars.example \
   stages/stage-8-aws-migration/terraform/terraform.tfvars
# Edit terraform.tfvars: github_owner = "YOUR_GITHUB_USERNAME"   # your GitHub user or org, not a placeholder

terraform -chdir=stages/stage-8-aws-migration/terraform validate
# Fails with "Set github_owner in terraform.tfvars" until you replace YOUR_GITHUB_USERNAME
</code></pre>
<p><strong>Don't run</strong> <code>terraform apply</code> <strong>until</strong> <code>github_owner</code> <strong>is set.</strong> If you apply with the placeholder, AWS creates IAM role <code>clearledger-github-actions-ecr</code> with trust <code>repo:YOUR_GITHUB_USERNAME/...</code>. CI then fails at <strong>Publish images → ECR</strong> with <code>Not authorized to perform sts:AssumeRoleWithWebIdentity</code>.</p>
<p>Fix: edit <code>terraform.tfvars</code> → <code>terraform apply</code> again → verify with <code>aws iam get-role</code> below then <strong>Re-run failed jobs</strong> on the failed Actions run (not the full pipeline).</p>
<h4 id="heading-steps-12-terraform">Steps 1–2: Terraform</h4>
<pre><code class="language-bash">cd stages/stage-8-aws-migration/terraform
terraform init -upgrade
terraform apply

# Save outputs:
terraform output -raw ecr_registry_url
terraform output -raw github_actions_ecr_role_arn
terraform output -raw eso_role_arn
terraform output -raw auth_service_irsa_role_arn
terraform output -raw kubeconfig_command
cd ../../..
</code></pre>
<h4 id="heading-aws-console-after-step-2">AWS Console after step 2.</h4>
<p>Confirm Terraform created resources before you touch the cluster:</p>
<ol>
<li><p><strong>EKS</strong> → Clusters → <code>clearledger</code> → <strong>Status: Active</strong>, <strong>3 nodes</strong></p>
</li>
<li><p><strong>ECR</strong> → Repositories → <code>clearledger/auth-service</code>, <code>ledger-service</code>, <code>notification-service</code>, <code>frontend</code> (0 images until step 4 or CI)</p>
</li>
<li><p><strong>RDS</strong> → Databases → <code>clearledger-postgres</code> → <strong>Available</strong></p>
</li>
</ol>
<p><strong>Verify GitHub can push to ECR (only if you plan to use AWS CI later)</strong></p>
<p>GitHub Actions needs permission to push images to your AWS account. Terraform creates an IAM role for that, but only if you set your real GitHub username in <code>terraform.tfvars</code> before <code>terraform apply</code>.</p>
<p>Check it worked:</p>
<pre><code class="language-bash">aws iam get-role --role-name clearledger-github-actions-ecr \
  --query 'Role.AssumeRolePolicyDocument.Statement[0].Condition.StringEquals."token.actions.githubusercontent.com:sub"' \
  --output text
</code></pre>
<p><strong>Good:</strong> <code>repo:your-real-username/clearledger:environment:production</code></p>
<p><strong>Bad:</strong> <code>repo:YOUR_GITHUB_USERNAME/clearledger:...</code> you forgot to edit <code>terraform.tfvars</code>.</p>
<p>Fix the file, run <code>terraform apply</code> again, then in GitHub go to Actions and then the failed CI, AWS (ECR + OIDC) run → click Re-run failed jobs. That retries only the push step. You don't need to rebuild and rescan everything.</p>
<p>Skip this whole block if you are only using <code>make aws-up</code> for now and not enabling AWS CI yet.</p>
<p><strong>When do ECR repos appear?</strong></p>
<p>During <code>terraform apply</code> <strong>(step 2)</strong>, not when you <code>docker push</code>. Terraform creates <strong>empty</strong> image repositories: <code>clearledger/auth-service</code>, <code>ledger-service</code>, <code>notification-service</code>, and <code>frontend</code>, so seeing 0 images right after apply is normal.</p>
<p>Images land later in step 4 (manual <code>docker push</code>) or when GitHub Actions CI succeeds.</p>
<p><strong>Set your AWS CLI region to</strong> <code>eu-west-1</code></p>
<p>Everything in this lab lives in eu-west-1 (Ireland). If your CLI defaults to <code>us-east-1</code>, commands will say resources are missing even though they exist:</p>
<pre><code class="language-bash">aws configure set region eu-west-1
aws configure get region   # expect: eu-west-1
</code></pre>
<h4 id="heading-steps-34-security-services-ecr-images">Steps 3–4: Security services + ECR images</h4>
<pre><code class="language-bash">AWS_REGION=eu-west-1   # or rely on aws configure set region above

# Step 3: verify security services (must pass --region eu-west-1)
aws guardduty list-detectors --region "${AWS_REGION}"
# Expect: DetectorIds: ["&lt;id&gt;"]  — empty [] means wrong region, not "not created"

aws cloudtrail get-trail-status --name clearledger-trail --region "${AWS_REGION}"
# Expect: IsLogging: true
# Error "Unknown trail ... us-east-1" → you forgot --region eu-west-1

# Step 4: build and push images to the ECR repos Terraform already created
ECR_REGISTRY=$(terraform -chdir=stages/stage-8-aws-migration/terraform output -raw ecr_registry_url)
AUTH_ECR=$(terraform -chdir=stages/stage-8-aws-migration/terraform output -raw auth_service_ecr_url)
LEDGER_ECR=$(terraform -chdir=stages/stage-8-aws-migration/terraform output -raw ledger_service_ecr_url)
NOTIFY_ECR=$(terraform -chdir=stages/stage-8-aws-migration/terraform output -raw notification_service_ecr_url)
TAG=$(git rev-parse --short HEAD)

aws ecr get-login-password --region "${AWS_REGION}" \
  | docker login --username AWS --password-stdin "${ECR_REGISTRY}"

docker build -t "${AUTH_ECR}:${TAG}" app/auth-service &amp;&amp; docker push "${AUTH_ECR}:${TAG}"
docker build -t "${LEDGER_ECR}:${TAG}" app/ledger-service &amp;&amp; docker push "${LEDGER_ECR}:${TAG}"
docker build -t "${NOTIFY_ECR}:${TAG}" app/notification-service &amp;&amp; docker push "${NOTIFY_ECR}:${TAG}"

# Confirm images landed (optional)
aws ecr describe-images --repository-name clearledger/auth-service --region "${AWS_REGION}" \
  --query 'imageDetails[*].imageTags' --output table
</code></pre>
<p><strong>ECR console (after step 4 or green CI)</strong>: open each repository and go. tothe Images tab. You should see tags matching your git commit SHA. If repos are empty, ArgoCD will show <code>ImagePullBackOff</code> later.</p>
<p><strong>GitHub Actions (if using CI instead of manual push)</strong>: repo → Actions → workflow CI. AWS (ECR + OIDC).</p>
<p>If all jobs are green, publish the images. ECR succeeded. This is the supply-chain proof before deploy.</p>
<h4 id="heading-step-5-gitops-source-of-truth">Step 5: GitOps source of truth</h4>
<p>Patch placeholders in <code>kustomization.yaml</code> (same <code>sed</code> as <code>aws-spinup.sh</code> step 5):</p>
<pre><code class="language-bash">AWS_REGION=eu-west-1
ECR_REGISTRY=$(terraform -chdir=stages/stage-8-aws-migration/terraform output -raw ecr_registry_url)
TAG=$(git rev-parse --short HEAD)
KUST=stages/stage-8-aws-migration/manifests/kustomization.yaml

sed -i.bak \
  -e "s|REPLACE_ECR_REGISTRY|${ECR_REGISTRY}|g" \
  -e "s|REPLACE_IMAGE_TAG|${TAG}|g" \
  "${KUST}"
rm -f "${KUST}.bak"

# Region in ESO + CSI manifests (only if not eu-west-1)
if [[ "${AWS_REGION}" != "eu-west-1" ]]; then
  sed -i.bak "s|region: eu-west-1|region: ${AWS_REGION}|g" \
    stages/stage-8-aws-migration/manifests/external-secrets.yaml \
    stages/stage-8-aws-migration/manifests/csi/auth-service-spc.yaml \
    stages/stage-8-aws-migration/manifests/csi/ledger-service-spc.yaml
  rm -f stages/stage-8-aws-migration/manifests/external-secrets.yaml.bak \
        stages/stage-8-aws-migration/manifests/csi/*.bak 2&gt;/dev/null || true
fi

# Verify before commit
grep -E 'newName:|newTag:' "${KUST}"
# Expect: YOUR_AWS_ACCOUNT.dkr.ecr.eu-west-1.amazonaws.com/clearledger/... and your git SHA

git add stages/stage-8-aws-migration/manifests/kustomization.yaml
git commit -m "stage8: ECR images ${TAG}"
git push
</code></pre>
<p>Also fix the ArgoCD Application repo URL once (replace with your GitHub username):</p>
<pre><code class="language-bash"># Example: YOUR_GITHUB_USERNAME/clearledger — check: git remote get-url origin
sed -i.bak 's|YOUR_GITHUB_USERNAME|YOUR_ACTUAL_GITHUB_USER|g' \
  stages/stage-8-aws-migration/argocd/clearledger-aws-app.yaml
rm -f stages/stage-8-aws-migration/argocd/clearledger-aws-app.yaml.bak
</code></pre>
<h4 id="heading-step-6-cluster-access-terraform-outputs">Step 6: Cluster access + Terraform outputs</h4>
<p>Run from the repo root. Set the CLI region first (EKS and IAM outputs are regional), then kubeconfig, then export IRSA role ARNs: steps 9–10 need them.</p>
<pre><code class="language-bash">aws configure set region eu-west-1

eval "$(terraform -chdir=stages/stage-8-aws-migration/terraform output -raw kubeconfig_command)"
kubectl get nodes

export AWS_REGION=eu-west-1
export ESO_ROLE_ARN=$(terraform -chdir=stages/stage-8-aws-migration/terraform output -raw eso_role_arn)
export FALCO_ROLE_ARN=$(terraform -chdir=stages/stage-8-aws-migration/terraform output -raw falco_role_arn)
export REPLACE_AUTH_IRSA_ROLE_ARN=$(terraform -chdir=stages/stage-8-aws-migration/terraform output -raw auth_service_irsa_role_arn)
export REPLACE_LEDGER_IRSA_ROLE_ARN=$(terraform -chdir=stages/stage-8-aws-migration/terraform output -raw ledger_service_irsa_role_arn)
export REPLACE_NOTIFICATION_IRSA_ROLE_ARN=$(terraform -chdir=stages/stage-8-aws-migration/terraform output -raw notification_service_irsa_role_arn)

# Sanity check (all should print ARNs, not empty)
echo "ESO:      ${ESO_ROLE_ARN}"
echo "Falco:    ${FALCO_ROLE_ARN}"
echo "Auth IRSA: ${REPLACE_AUTH_IRSA_ROLE_ARN}"
</code></pre>
<h4 id="heading-steps-712-platform-stack-on-the-cluster">Steps 7–12: Platform stack on the cluster</h4>
<p>You finished steps 1–6 (AWS exists, images in ECR, <code>kubectl</code> works). Now for steps 7-12 you'll install the platform stack, the same components as <code>aws-spinup.sh</code>, but you run the commands from the sections below, not the script.</p>
<p>For each step, run the Install code block, then run the Verify block right under it. Don't move to the next step until you see Running pods (or a ClusterPolicy list). “Command finished with no output” isn't enough.</p>
<table>
<thead>
<tr>
<th>Step</th>
<th>Namespace</th>
<th>What you are installing</th>
<th>Rough pod count</th>
</tr>
</thead>
<tbody><tr>
<td>7</td>
<td><code>argocd</code></td>
<td>GitOps controller</td>
<td>~7 pods</td>
</tr>
<tr>
<td>8</td>
<td><code>kyverno</code></td>
<td>Admission policies</td>
<td>~4 pods + ClusterPolicies</td>
</tr>
<tr>
<td>9</td>
<td><code>falco</code></td>
<td>Runtime detection</td>
<td>1 DaemonSet pod <strong>per node</strong> (3 on this cluster)</td>
</tr>
<tr>
<td>10</td>
<td><code>external-secrets</code> + <code>clearledger</code></td>
<td>ESO + IRSA ServiceAccounts</td>
<td>~3 ESO pods + 3 ServiceAccounts</td>
</tr>
<tr>
<td>11</td>
<td><code>kube-system</code> + <code>clearledger</code></td>
<td>CSI driver + AWS provider</td>
<td>3 driver + 3 provider (one per node)</td>
</tr>
<tr>
<td>12</td>
<td><code>monitoring</code></td>
<td>Prometheus, Grafana, Loki</td>
<td>~10+ pods</td>
</tr>
</tbody></table>
<p>Steps 13–15 (deploy app, wait for ALB, verify UI) come after step 12 below.</p>
<h4 id="heading-step-7-argocd">Step 7: ArgoCD</h4>
<pre><code class="language-bash">kubectl create namespace argocd --dry-run=client -o yaml | kubectl apply -f -
kubectl apply -n argocd --server-side --force-conflicts \
  -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
kubectl rollout status deployment/argocd-server -n argocd --timeout=180s
</code></pre>
<p><strong>Verify what got created:</strong></p>
<pre><code class="language-bash">kubectl get pods -n argocd
kubectl get svc -n argocd
kubectl get deploy -n argocd
</code></pre>
<p><strong>Expected:</strong> <code>argocd-server</code>, <code>argocd-repo-server</code>, <code>argocd-application-controller</code>, and so on: most pods <strong>Running</strong> <strong>1/1</strong> or <strong>2/2</strong>. <code>argocd-server</code> Service exposes port 443.</p>
<p><strong>UI (optional now, required after step 13):</strong> new terminal, leave running. Use any free local port (<code>8081</code> if <code>8080</code> is in use):</p>
<pre><code class="language-bash">kubectl port-forward svc/argocd-server -n argocd 8080:443
# Or if 8080 is taken:
# kubectl port-forward svc/argocd-server -n argocd 8081:443
# https://localhost:8080 (or 8081)  user: admin
kubectl get secret argocd-initial-admin-secret -n argocd -o jsonpath='{.data.password}' | base64 -d; echo
</code></pre>
<p>Applications list is empty until step 13. That's normal.</p>
<h4 id="heading-step-8-kyverno-policies">Step 8: Kyverno + policies</h4>
<p><code>cosign.pub</code> / <code>infra/cosign.pub</code> are gitignored (private key must never commit; public key is learner-specific).<br>The repo ships example keys in <code>require-signed-images.yaml</code> / <code>require-signed-images-ecr.yaml</code>.<br>If you regenerated keys in Stage 3, sync your local public key into policies before apply:</p>
<pre><code class="language-bash"># infra/cosign.pub exists locally but is gitignored — safe to copy into committed policy YAMLs
bash scripts/embed-cosign-pub-in-policies.sh
diff infra/cosign.pub &lt;(grep -A3 'BEGIN PUBLIC KEY' infra/policies/require-signed-images-ecr.yaml | grep -v publicKeys)
</code></pre>
<pre><code class="language-bash">helm repo add kyverno https://kyverno.github.io/kyverno/ --force-update
helm upgrade --install kyverno kyverno/kyverno \
  --namespace kyverno --create-namespace \
  -f stages/stage-4-admission-control/infra/kyverno/values.yaml \
  --set admissionController.replicas=1 \
  --wait --timeout=180s
kubectl apply -f infra/policies/
</code></pre>
<p><strong>Verify:</strong></p>
<pre><code class="language-bash">kubectl get pods -n kyverno
kubectl get clusterpolicy
kubectl get clusterpolicy require-signed-images-ecr -o jsonpath='{.spec.rules[0].verifyImages[0].attestors[0].entries[0].keys.publicKeys}' | head -3
</code></pre>
<p><strong>Expected:</strong> admission-controller, background-controller, cleanup-controller, reports-controller pods Running.</p>
<p><code>kubectl get clusterpolicy</code> lists 6+ policies including <code>require-signed-images-ecr</code>, <code>disallow-root-containers</code>, and so on. The <code>publicKeys</code> output must show <code>-----BEGIN PUBLIC KEY-----</code>, not <code>PASTE_YOUR_COSIGN_PUBLIC_KEY_HERE</code> (Kyverno treats a placeholder as a file path and blocks all deploys).</p>
<p><code>require-signed-images-ecr</code> defaults to Audit until CI Cosign-signs ECR images (<code>COSIGN_PRIVATE_KEY</code> + <code>COSIGN_PASSWORD</code> in GitHub). Unsigned images still deploy. Signed-image enforcement is optional later.</p>
<p>If <code>verify-slsa-provenance</code> fails to apply (Audit + <code>mutateDigest</code>), set <code>mutateDigest: false</code> in that file, or skip it. It's optional for Stage 8.</p>
<h4 id="heading-step-9-falco">Step 9: Falco</h4>
<pre><code class="language-bash">helm repo add falcosecurity https://falcosecurity.github.io/charts --force-update
helm upgrade --install falco falcosecurity/falco \
  --namespace falco --create-namespace \
  -f stages/stage-6-runtime-security/infra/falco/helm-values.yaml \
  --set driver.kind=modern_ebpf \
  --set "serviceAccount.annotations.eks\.amazonaws\.com/role-arn=${FALCO_ROLE_ARN}" \
  --wait --timeout=300s
</code></pre>
<p><strong>Verify:</strong></p>
<pre><code class="language-bash">kubectl get pods -n falco -o wide
kubectl get daemonset -n falco
kubectl get sa falco -n falco -o jsonpath='{.metadata.annotations.eks\.amazonaws\.com/role-arn}'; echo
</code></pre>
<p><strong>Expected:</strong> Falco DaemonSet with DESIRED = number of nodes (3). Each pod <strong>Running</strong>. ServiceAccount annotation shows your <code>FALCO_ROLE_ARN</code>.</p>
<h4 id="heading-step-10-external-secrets-operator-irsa-serviceaccounts">Step 10: External Secrets Operator + IRSA ServiceAccounts</h4>
<pre><code class="language-bash">helm repo add external-secrets https://charts.external-secrets.io --force-update
helm upgrade --install external-secrets external-secrets/external-secrets \
  --namespace external-secrets --create-namespace \
  --set "serviceAccount.annotations.eks\.amazonaws\.com/role-arn=${ESO_ROLE_ARN}" \
  --wait --timeout=180s
kubectl apply -f stages/stage-8-aws-migration/manifests/resources/namespace.yaml
envsubst &lt; stages/stage-8-aws-migration/manifests/clearledger-serviceaccounts.yaml | kubectl apply -f -
</code></pre>
<p><strong>Verify:</strong></p>
<pre><code class="language-bash">kubectl get pods -n external-secrets
kubectl get sa -n external-secrets external-secrets -o jsonpath='{.metadata.annotations.eks\.amazonaws\.com/role-arn}'; echo
kubectl get sa -n clearledger
</code></pre>
<p><strong>Expected:</strong> <code>external-secrets</code> deployment <strong>Running</strong> (often 3 containers / 1 pod). Three ServiceAccounts in <code>clearledger</code>: <code>auth-service</code>, <code>ledger-service</code>, <code>notification-service</code>: each with an <code>eks.amazonaws.com/role-arn</code> annotation. No app pods yet (ArgoCD deploys those in step 13).</p>
<p><strong>Step 11: CSI driver + SecretProviderClasses</strong></p>
<pre><code class="language-bash">bash stages/stage-8-aws-migration/scripts/install-csi-secrets.sh
</code></pre>
<p><strong>Verify:</strong></p>
<pre><code class="language-bash">kubectl get pods -n kube-system | grep -E 'secrets-store|provider-aws'
kubectl get secretproviderclass -n clearledger
helm list -n kube-system | grep -E 'csi-secrets|secrets-provider'
</code></pre>
<p><strong>Expected:</strong> CSI driver pods <strong>3/3 Running</strong> (one per node). AWS provider pods <strong>1/1 Running</strong> per node. Two <code>SecretProviderClass</code> objects in <code>clearledger</code>. Helm shows <code>csi-secrets-store</code> and/or <code>secrets-provider-aws</code> <strong>deployed</strong>.</p>
<p>If Helm reports <code>meta.helm.sh/release-name</code> conflicts, re-run the script. It installs the AWS provider without duplicating the driver chart.</p>
<h4 id="heading-step-12-observability">Step 12: Observability</h4>
<pre><code class="language-bash">bash stages/stage-7-observability/scripts/install-observability.sh
</code></pre>
<p><strong>Verify:</strong></p>
<pre><code class="language-bash">kubectl get pods -n monitoring
kubectl get svc -n monitoring | grep -E 'grafana|prometheus|loki'
kubectl get configmap -n monitoring -l grafana_dashboard=1 --no-headers | wc -l
</code></pre>
<p><strong>Expected:</strong> Grafana <strong>3/3 Running</strong>, Prometheus and Loki pods <strong>Running</strong>. ConfigMap count for dashboards is <strong>6</strong> (ClearLedger dashboards). Script prints <code>http://grafana.local</code>: on EKS use port-forward instead:</p>
<pre><code class="language-bash"># New terminal — keep running
kubectl port-forward -n monitoring svc/kube-prometheus-stack-grafana 3000:80
# http://localhost:3000  admin / admin123
# http://localhost:3000/dashboards?tag=clearledger
</code></pre>
<p>Panels may show <strong>No data</strong> until you trigger events (§7.4 exercises work on this cluster too).</p>
<p><strong>Platform stack summary</strong>: quick sanity check before step 13:</p>
<pre><code class="language-bash">for ns in argocd kyverno falco external-secrets monitoring clearledger; do
  echo "=== ${ns} ==="
  kubectl get pods -n "${ns}" --no-headers 2&gt;/dev/null | awk '{print $3}' | sort | uniq -c || echo "(no pods yet)"
done
kubectl get clusterpolicy --no-headers | wc -l | xargs echo "ClusterPolicies:"
kubectl get secretproviderclass -n clearledger --no-headers | wc -l | xargs echo "SecretProviderClasses:"
</code></pre>
<p><strong>Expected:</strong> every namespace shows only <code>Running</code> (or <code>Completed</code> for jobs). <code>clearledger</code> may be empty until ArgoCD syncs. ClusterPolicies ≥ 6. SecretProviderClasses = 2.</p>
<p><strong>EKS API timeout on namespace create?</strong> You may see <code>Unexpected error when reading response body</code> / <code>context deadline exceeded</code> and still get <code>namespace/argocd created</code>. That's a <strong>transient client timeout</strong> talking to the EKS API (first request, slow network, or control plane catching up), not a failed create. Confirm with <code>kubectl get namespace argocd</code> and continue. If commands keep timing out, retry once or run <code>kubectl cluster-info</code> to verify connectivity.</p>
<h4 id="heading-steps-1314-deploy-via-argocd-see-the-alb">Steps 13–14: Deploy via ArgoCD + see the ALB</h4>
<p>The app YAMLs under <code>stages/stage-8-aws-migration/manifests/</code> aren't applied by hand. Step 13 tells Argo CD to sync Git. Argo CD then creates Deployments, Services, Ingress, and the rest.</p>
<p><strong>Repo access first</strong></p>
<p>If your GitHub repo is private, add a PAT in Argo CD → Settings → Repositories. If you made the repo public, refresh the app, <code>ComparisonError: authentication required</code> should clear.</p>
<p><strong>If sync still fails</strong>, check the usual causes:</p>
<ul>
<li><p><code>external-secrets.io/v1beta1</code> <strong>not found</strong>, your cluster has a newer ESO API. Push <code>external-secrets.yaml</code> with <code>apiVersion: external-secrets.io/v1</code>.</p>
</li>
<li><p><strong>Kyverno complains about</strong> <code>PASTE_YOUR_COSIGN_PUBLIC_KEY_HERE</code> , run <code>bash scripts/embed-cosign-pub-in-policies.sh</code>, then <code>kubectl apply -f infra/policies/</code>.</p>
</li>
<li><p><code>SecretSyncedError</code> <strong>on auth,</strong> <code>database_url</code> <strong>or</strong> <code>jwt_secret</code> <strong>not found</strong> — the AWS secret <code>clearledger/auth-service</code> must contain both keys (Terraform writes them in <code>secrets.tf</code>). Re-run <code>terraform apply</code> after fixing <code>CHANGE_ME_BEFORE_APPLY</code> values, or check the secret in the AWS console.</p>
</li>
<li><p><strong>Pods stuck</strong> <code>Pending</code> <strong>or “too many pods”</strong> the lab nodes are small. Scale the node group in Terraform or lower replica counts in the manifests.</p>
</li>
</ul>
<p>Register the app:</p>
<pre><code class="language-bash">kubectl apply -f stages/stage-8-aws-migration/argocd/clearledger-aws-app.yaml
</code></pre>
<p>Watch Argo CD until <code>clearledger-aws</code> is <strong>Synced</strong> and <strong>Healthy</strong>. That's when app pods appear in <code>clearledger</code>.</p>
<h4 id="heading-step-13-watch-argocd-sync-ui-cli">Step 13: Watch ArgoCD sync (UI + CLI)</h4>
<p>Open the Argo CD browser tab you kept open (port-forward from step 7).</p>
<pre><code class="language-plaintext">https://localhost:8080        ← or 8081 if 8080 was busy
</code></pre>
<p>Click <code>clearledger-aws</code>. Wait for <strong>Healthy + Synced</strong> (2–5 minutes on first deploy). You can watch the same info from the terminal without touching the browser:</p>
<pre><code class="language-bash">kubectl get application clearledger-aws -n argocd -w
# Ctrl-C when HEALTH STATUS shows Healthy
</code></pre>
<p>While that's settling, watch pods start up in a second terminal:</p>
<pre><code class="language-bash">kubectl get pods -n clearledger -w
# All pods should reach 1/1 Running within 2 minutes
# Ctrl-C when everything is Running
</code></pre>
<h4 id="heading-step-14-get-your-public-app-url-alb">Step 14: Get your public app URL (ALB)</h4>
<p>AWS takes 2–5 minutes after ArgoCD syncs to provision the load balancer.<br>Run this and wait until the ADDRESS column fills in:</p>
<pre><code class="language-bash">kubectl get ingress clearledger-ingress -n clearledger -w
# ADDRESS is empty at first, then shows something like:
# clearledger-xxxxxxxxxx.eu-west-1.elb.amazonaws.com
# Ctrl-C once the hostname appears
</code></pre>
<p>Export the URL for the steps below:</p>
<pre><code class="language-bash">export ALB_DNS=$(kubectl get ingress clearledger-ingress -n clearledger \
  -o jsonpath='{.status.loadBalancer.ingress[0].hostname}')
echo "Your app is live at: http://${ALB_DNS}"
</code></pre>
<p><strong>Still empty after 10 minutes?</strong> See <a href="https://github.com/Osomudeya/clearledger/blob/main/docs/troubleshooting.md">troubleshooting.md</a> for ALB/ingress recovery steps.</p>
<h4 id="heading-step-15-open-the-app-in-your-browser">Step 15: Open the app in your browser</h4>
<p>Paste the ALB root URL into your browser. No DNS entry, port-forward, or VPN:</p>
<pre><code class="language-plaintext">http://clearledger-xxxxxxxxxx.eu-west-1.elb.amazonaws.com/
</code></pre>
<p>You should see the ClearLedger login screen (same SPA as homelab <code>clearledger.local</code>). Register or log in, submit a transaction, and confirm the dashboard loads. That's your Stage 8 portfolio screenshot.</p>
<p><strong>Quick API health checks</strong> (terminal or browser):</p>
<pre><code class="language-bash">curl -fsS "http://${ALB_DNS}/auth/health" &amp;&amp; echo
curl -fsS "http://${ALB_DNS}/ledger/health" &amp;&amp; echo
curl -fsS "http://${ALB_DNS}/notifications/health" &amp;&amp; echo
</code></pre>
<p>Each should return JSON like <code>{"status":"ok","service":"auth-service"}</code>.</p>
<h4 id="heading-step-16-verify-in-the-aws-console-optional-but-recommended">Step 16: Verify in the AWS Console (optional but recommended)</h4>
<p>This is what the deployed stack looks like from AWS side:</p>
<table>
<thead>
<tr>
<th>Console location</th>
<th>What to look for</th>
</tr>
</thead>
<tbody><tr>
<td><strong>EC2 → Load Balancers</strong></td>
<td>A load balancer named <code>clearledger-…</code> with state <strong>Active</strong></td>
</tr>
<tr>
<td><strong>EC2 → Target Groups</strong></td>
<td>Two or three target groups, all targets showing <strong>healthy</strong></td>
</tr>
<tr>
<td><strong>ECR → Repositories</strong></td>
<td><code>clearledger/auth-service</code>, <code>clearledger/ledger-service</code>, <code>clearledger/notification-service</code>, <code>clearledger/frontend</code> — each with a recently pushed image tag</td>
</tr>
<tr>
<td><strong>EKS → Clusters → clearledger → Workloads</strong></td>
<td>Your pods shown as Running in the <code>clearledger</code> namespace</td>
</tr>
<tr>
<td><strong>Secrets Manager</strong></td>
<td><code>clearledger/auth-service</code>, <code>clearledger/ledger-service</code>, <code>clearledger/postgres</code> — all present</td>
</tr>
</tbody></table>
<p><strong>502/503 from the ALB?</strong> The load balancer is up but the pods aren't healthy yet, or the secrets haven't synced. Check: <code>kubectl get pods -n clearledger</code> (all <code>1/1 Running</code>?) and <code>kubectl get externalsecret -n clearledger</code> (both <code>SecretSynced True</code>?).</p>
<p><strong>✋ Hands-on checkpoint: app is publicly reachable</strong></p>
<pre><code class="language-bash"># All three must print {"status":"ok",...}
curl -fsS "http://${ALB_DNS}/auth/health"         &amp;&amp; echo
curl -fsS "http://${ALB_DNS}/ledger/health"        &amp;&amp; echo
curl -fsS "http://${ALB_DNS}/notifications/health" &amp;&amp; echo

# All pods Running
kubectl get pods -n clearledger

# Nothing printed here = all pods Running (non-Running pods would show)
kubectl get pods -n clearledger --field-selector=status.phase!=Running
</code></pre>
<p><code>ImagePullBackOff</code> in the pod list means ECR images aren't there yet. Check GitHub Actions and re-run the workflow. A <code>502</code> from the health URL means the pod isn't ready yet. Wait 30 seconds and retry.</p>
<h3 id="heading-84-verify-eso-default-secret-path">8.4: Verify ESO (Default Secret Path)</h3>
<p>After Argo CD syncs, confirm External Secrets Operator copied values from AWS Secrets Manager into normal Kubernetes Secrets:</p>
<pre><code class="language-bash">kubectl get externalsecret,secret -n clearledger
kubectl describe externalsecret auth-service-secret -n clearledger | grep -A6 "Conditions:"
kubectl get pods -n clearledger -l app=auth-service
kubectl exec -n clearledger deploy/auth-service -c auth-service -- env | grep DATABASE_URL
</code></pre>
<h4 id="heading-command-1-externalsecrets-secrets">Command 1: ExternalSecrets + Secrets</h4>
<p>You should see two ExternalSecrets and two matching Secrets (auth has 2 keys, ledger has 1):</p>
<pre><code class="language-plaintext">NAME                                                     STORE                 REFRESH INTERVAL   STATUS         READY
externalsecret.external-secrets.io/auth-service-secret   aws-secrets-manager   1h                 SecretSynced   True
externalsecret.external-secrets.io/ledger-service-secret aws-secrets-manager   1h                 SecretSynced   True

NAME                         TYPE     DATA   AGE
secret/auth-service-secret   Opaque   2      3m
secret/ledger-service-secret Opaque   1      3m
</code></pre>
<p><code>STATUS</code> must be <strong>SecretSynced</strong> and <strong>READY</strong> must be <strong>True</strong>. If you see <code>SecretSyncedError</code>, stop here and fix IRSA before §8.5.</p>
<h4 id="heading-command-2-describe-auth-externalsecret">Command 2: describe auth ExternalSecret</h4>
<p>Look for <code>Reason: SecretSynced</code> and <code>Status: True</code>:</p>
<pre><code class="language-plaintext">  Conditions:
    Last Transition Time:   2026-07-10T22:15:00Z
    Message:                Secret was synced
    Reason:                 SecretSynced
    Status:                 True
    Type:                   Ready
</code></pre>
<h4 id="heading-command-3-auth-pods-running">Command 3: auth pods running</h4>
<pre><code class="language-plaintext">NAME                            READY   STATUS    RESTARTS   AGE
auth-service-xxxxxxxxxx-xxxxx   1/1     Running   0          2m
auth-service-xxxxxxxxxx-xxxxx   1/1     Running   0          2m
</code></pre>
<p>Both replicas <strong>1/1 Running</strong>. If pods are <code>CrashLoopBackOff</code> or <code>CreateContainerConfigError</code>, the K8s Secret may be missing or empty.</p>
<h4 id="heading-command-4-databaseurl-is-an-env-var-eso-path-not-a-file-path">Command 4: DATABASE_URL is an env var (ESO path), not a file path</h4>
<pre><code class="language-plaintext">DATABASE_URL=postgresql://clearledger:*****@clearledger-postgres.xxxxx.eu-west-1.rds.amazonaws.com:5432/clearledger
</code></pre>
<p>Good: a <code>postgresql://...</code> connection string (password shown as <code>*****</code> or your real password).</p>
<p>Bad for this section: <code>/mnt/secrets/database_url</code> that means CSI file mounts (§8.5), not the default ESO env-var path.</p>
<p>You can also spot-check the secret exists without printing values:</p>
<pre><code class="language-bash">kubectl get secret auth-service-secret -n clearledger -o jsonpath='{.data}' | grep -o 'database_url\|jwt_secret'
# Expect: database_url and jwt_secret (two keys)
</code></pre>
<p>If <code>SecretSynced=False</code>, check ESO logs and IRSA:</p>
<pre><code class="language-bash">kubectl logs -n external-secrets deploy/external-secrets -c external-secrets | tail -30
kubectl get sa auth-service -n clearledger -o yaml | grep role-arn
</code></pre>
<p><strong>✋ Hands-on checkpoint. External Secrets actually synced from AWS</strong></p>
<pre><code class="language-bash">kubectl get externalsecret -n clearledger
kubectl get secret -n clearledger
</code></pre>
<p>Expected: <code>auth-service-secret</code> and <code>ledger-service-secret</code> each show <code>SecretSynced</code> / Ready <code>True</code>. The matching Kubernetes Secrets exist in <code>clearledger</code>. A <code>SecretSyncedError</code> means IRSA/IAM can't reach Secrets Manager: fix the role binding before §8.5.</p>
<p>If you skip this, §8.5 (CSI driver) builds on working secret access, and a silent IAM failure here surfaces as an unrelated-looking pod error two sections later.</p>
<h3 id="heading-85-hands-on-csi-driver-file-mounts">8.5: Hands-on, CSI Driver (File Mounts)</h3>
<p>The default pods already use ESO: secrets arrive as environment variables from a Kubernetes Secret object. This exercise switches <code>auth-service</code> to the CSI path instead: secrets are mounted as plain files under <code>/mnt/secrets/</code>, and the app reads them from disk. It's the same code path the homelab uses with Vault (<code>DATABASE_URL_FILE</code> / <code>JWT_SECRET_FILE</code>).</p>
<p>CSI was already installed at spinup step 11, so there's nothing extra to install.</p>
<h4 id="heading-step-1-confirm-csi-is-running">Step 1: Confirm CSI is running</h4>
<pre><code class="language-bash">kubectl get pods -n kube-system -l app=secrets-store-csi-driver
kubectl get secretproviderclass -n clearledger
</code></pre>
<p>You should see one CSI driver pod per node, and two <code>SecretProviderClass</code> objects: one for auth-service and one for ledger-service.</p>
<h4 id="heading-step-2-swap-the-deployment-in-git">Step 2: swap the deployment in Git</h4>
<p>Open <code>stages/stage-8-aws-migration/manifests/kustomization.yaml</code> and change one line:</p>
<pre><code class="language-yaml"># Before
  - deployments/auth-service.yaml

# After
  - deployments/auth-service-csi.yaml
</code></pre>
<p>Commit and push, then sync:</p>
<pre><code class="language-bash">argocd app sync clearledger-aws
kubectl rollout status deployment/auth-service -n clearledger
</code></pre>
<p>ArgoCD will roll out a new auth-service pod with the CSI volume attached.</p>
<h4 id="heading-step-3-confirm-the-files-are-there">Step 3: Confirm the files are there</h4>
<pre><code class="language-bash"># Find the new pod
kubectl get pod -n clearledger -l secrets=csi

# List the mounted secret files
kubectl exec -n clearledger deploy/auth-service -- ls /mnt/secrets

# Check the database URL was written correctly
kubectl exec -n clearledger deploy/auth-service -- cat /mnt/secrets/database_url

# Confirm the service is still healthy
curl -s "http://$(kubectl get ingress clearledger-ingress -n clearledger \
  -o jsonpath='{.status.loadBalancer.ingress[0].hostname}')/auth/health"
</code></pre>
<p>You should see <code>database_url</code> and <code>jwt_secret</code> listed as files, and the health check should return <code>{"status":"ok"}</code>.</p>
<p><strong>ESO vs CSI: what actually changed?</strong></p>
<p>Both paths read the same passwords from AWS Secrets Manager. Only the delivery method changes.</p>
<p><strong>ESO (default, what you verified in §8.4)</strong></p>
<p>Think of ESO as a copy clerk that runs in the cluster:</p>
<ol>
<li><p>ESO has its own AWS permission (IAM role).</p>
</li>
<li><p>It reads <code>clearledger/auth-service</code> from Secrets Manager.</p>
</li>
<li><p>It copies the values into a normal Kubernetes Secret named <code>auth-service-secret</code>.</p>
</li>
<li><p>The auth pod reads <code>DATABASE_URL</code> and <code>JWT_SECRET</code> as <strong>environment variables.</strong></p>
</li>
</ol>
<p>The password lives briefly inside the cluster as a Kubernetes Secret object.</p>
<p><strong>CSI (this exercise, file mounts)</strong></p>
<p>Think of CSI as the pod picking up secrets itself when it starts:</p>
<ol>
<li><p>The auth-service pod has its own AWS permission (IRSA on its ServiceAccount).</p>
</li>
<li><p>When the pod starts, the CSI driver asks Secrets Manager for the values.</p>
</li>
<li><p>They appear as files under <code>/mnt/secrets/</code> (<code>database_url</code>, <code>jwt_secret</code>).</p>
</li>
<li><p>The pod is told <code>DATABASE_URL_FILE=/mnt/secrets/database_url</code> , it reads from disk, not from a copied K8s Secret.</p>
</li>
</ol>
<p>No Kubernetes Secret copy is created for those values on this path.</p>
<p><strong>Why does the same app code work for both?</strong></p>
<p><code>app/auth-service/main.py</code> uses a small helper <code>_read_secret()</code>:</p>
<ul>
<li><p>If <code>DATABASE_URL_FILE</code> points to a file that exists → read the file (CSI or homelab Vault).</p>
</li>
<li><p>Otherwise → read <code>DATABASE_URL</code> directly (ESO / Stage 0–4).</p>
</li>
</ul>
<p>Same image, same code: you only change which deployment YAML Argo CD syncs.</p>
<p><strong>To switch back to ESO:</strong> in <code>kustomization.yaml</code>, change <code>auth-service-csi.yaml</code> back to <code>auth-service.yaml</code>, commit, push, and <code>argocd app sync clearledger-aws</code>.</p>
<p><strong>Terraform</strong> provisions all the AWS resources (VPC, EKS, RDS, ECR, Secrets Manager, and IAM roles) from <code>.tf</code> files in <code>stages/stage-8-aws-migration/terraform/</code>.</p>
<h3 id="heading-two-oidc-ideas-in-stage-8">Two OIDC Ideas in Stage 8</h3>
<p>Stage 8 uses OIDC in two different places. They sound similar, but they solve different problems.</p>
<p><strong>GitHub Actions OIDC</strong> lets the CI pipeline push images to ECR without storing long-lived AWS keys in GitHub. When a job runs, GitHub mints a short-lived token that proves the job's identity. AWS trusts that token and hands back temporary credentials: enough to push images and nothing else.</p>
<p><strong>IRSA</strong> does the same thing, but for pods running inside EKS. Instead of a GitHub token, the pod presents its Kubernetes ServiceAccount token. AWS trusts the EKS cluster's OIDC provider, verifies the token, and returns temporary credentials scoped to exactly what that pod needs.</p>
<p>It helps to see what each one says:</p>
<pre><code class="language-text">GitHub Actions OIDC:
  Pipeline says → "I am a job in the production environment of YOUR_USERNAME/clearledger"
  AWS replies   → "Here are credentials to push to ECR, valid for one hour"

IRSA:
  Pod says   → "I am the auth-service ServiceAccount in the clearledger namespace"
  AWS replies → "Here are credentials to read only the auth-service secret, valid for one hour"
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/ac83e9bf-dbfd-42b3-bba8-0bbf327b03c5.png" alt="Image flow diagram showing difference between OIDC and IRSA" style="display: block;" width="1536" height="1024" loading="lazy">

<p>The key is what is <em>not</em> stored anywhere:</p>
<pre><code class="language-text">No AWS_ACCESS_KEY_ID in GitHub Secrets
No AWS_SECRET_ACCESS_KEY in GitHub Secrets
No AWS keys inside Kubernetes Secrets
</code></pre>
<p>Terraform creates the role <code>clearledger-github-actions-ecr</code> and wires up the trust policies for both. The pipeline in <code>.github/workflows/ci-aws.yaml</code> assumes that role, pushes images to ECR, and updates <code>kustomization.yaml</code>. ArgoCD picks up the change and deploys the new images.</p>
<h3 id="heading-ci-routing-stages-17-vs-stage-8">CI Routing: Stages 1–7 vs Stage 8</h3>
<p>The repo ships two workflow files. You don't need both running at the same time.</p>
<p><code>ci.yaml</code> is the homelab pipeline from Stages 1–7. It runs on your self-hosted Multipass VM, pushes images to Docker Hub, and updates your <code>clearledger-infra</code> GitOps repo. This is the default: nothing to configure.</p>
<p><code>ci-aws.yaml</code> is the AWS pipeline for Stage 8. It runs on GitHub-hosted <code>ubuntu-latest</code> runners, pushes images to ECR, and updates <code>kustomization.yaml</code> directly in this repo. It only activates when you set the repo variable <code>CLEARLEDGER_CI_TARGET=aws</code>.</p>
<p><strong>If you're on Stages 1–7, do nothing.</strong> The <code>CLEARLEDGER_CI_TARGET</code> variable is unset by default, so every push runs <code>ci.yaml</code> on your self-hosted runner as normal. The AWS workflow file exists in the repo but its jobs are skipped.</p>
<p><strong>Do not set</strong> <code>CLEARLEDGER_CI_TARGET=aws</code> <strong>until your EKS cluster is running.</strong></p>
<p>If you set it early, <code>ci.yaml</code> stops running on push (no more Docker Hub builds), and <code>ci-aws.yaml</code> will fail immediately because there's no ECR, no OIDC role, and no AWS infrastructure yet. If you accidentally set it, delete the variable: GitHub → repo <strong>Settings</strong> → <strong>Secrets and variables</strong> → <strong>Actions</strong> → <strong>Variables</strong> → delete <code>CLEARLEDGER_CI_TARGET</code>.</p>
<p><strong>Enabling AWS CI (do this after</strong> <code>terraform apply</code> <strong>completes)</strong></p>
<p>You need three repository variables and one secret in a <code>production</code> environment.</p>
<p>First, set the variables: replace <code>YOUR_USERNAME</code> with your GitHub username:</p>
<pre><code class="language-bash">gh variable set CLEARLEDGER_CI_TARGET --body aws --repo YOUR_USERNAME/clearledger

gh variable set AWS_ACCOUNT_ID --body "$(aws sts get-caller-identity --query Account --output text)" --repo YOUR_USERNAME/clearledger

gh variable set AWS_REGION --body eu-west-1 --repo YOUR_USERNAME/clearledger
</code></pre>
<p>Then create the <code>production</code> environment and add the OIDC role ARN as a secret:</p>
<pre><code class="language-bash"># Create the environment first, gh secret set returns 404 if it does not exist
gh api --method PUT "repos/YOUR_USERNAME/clearledger/environments/production"

gh secret set AWS_ACTIONS_ROLE_ARN \
  --env production \
  --body "$(terraform -chdir=stages/stage-8-aws-migration/terraform output -raw github_actions_ecr_role_arn)" \
  --repo YOUR_USERNAME/clearledger
</code></pre>
<p><strong>Note</strong>: GitHub blocks secret names that start with <code>GITHUB_</code>. Use <code>AWS_ACTIONS_ROLE_ARN</code>, not <code>GITHUB_ACTIONS_ROLE_ARN</code>.</p>
<p>Also make sure <code>github_owner</code> is set correctly in <code>terraform.tfvars</code> (see <code>terraform.tfvars.example</code>) before running <code>terraform apply</code>. This wires up the OIDC trust policy so AWS will accept tokens from your specific GitHub account.</p>
<p>Once <code>CLEARLEDGER_CI_TARGET=aws</code> is set, every push to <code>main</code> runs the AWS pipeline: Gitleaks → Semgrep → Checkov → build → Trivy scan → ECR push → kustomization update. The homelab <code>ci.yaml</code> is skipped.</p>
<p><strong>If CI fails at the ECR push step:</strong></p>
<p>The most common failure is <code>Not authorized to perform sts:AssumeRoleWithWebIdentity</code>. This means the IAM role trust policy still has a placeholder <code>YOUR_GITHUB_USERNAME</code> in the <code>:sub</code> condition. Fix it by setting <code>github_owner</code> in <code>terraform.tfvars</code> and running <code>terraform apply</code> again, then re-run only the failed job (not the whole pipeline: the earlier scan steps already passed).</p>
<pre><code class="language-text">GitHub → Actions → failed run → Re-run failed jobs
</code></pre>
<p>If you see <code>404</code> when running <code>gh secret set</code>, the <code>production</code> environment doesn't exist yet. Run the <code>gh api --method PUT</code> command above first.</p>
<p><strong>Re-run after fixing OIDC:</strong> failed jobs only, not the full pipeline. Earlier gates (Gitleaks, build, scan) already passed, and their artifacts are still in the workflow run. Use Re-run all jobs only if you changed app code or want a clean scan from scratch.</p>
<h3 id="heading-production-hardening-checklist">Production Hardening Checklist</h3>
<p>The lab architecture is production-style, but a real production setup needs extra guardrails. Add these before you describe it as production-ready.</p>
<h4 id="heading-1-protect-the-main-branches">1. Protect the main branches</h4>
<p>Protect both GitHub repos:</p>
<pre><code class="language-text">github.com/YOUR_GITHUB_USERNAME/clearledger
github.com/YOUR_GITHUB_USERNAME/clearledger-infra
</code></pre>
<p>Go to each repo:</p>
<pre><code class="language-text">Settings
→ Rules
→ Rulesets
→ New ruleset
→ Branch targeting: main
</code></pre>
<p>Enable:</p>
<pre><code class="language-text">Require a pull request before merging
Require approvals
Require status checks to pass
Require branches to be up to date before merging
Block force pushes
Block branch deletion
</code></pre>
<p>Why this matters: nobody should push straight to the code repo or the GitOps repo in production. A bad direct push to <code>clearledger-infra</code> is a direct deployment request.</p>
<h4 id="heading-2-use-github-environments-with-approvals">2. Use GitHub Environments with approvals</h4>
<p>Create a protected environment:</p>
<pre><code class="language-text">clearledger repo
→ Settings
→ Environments
→ New environment
→ Name: production
→ Required reviewers: add yourself or the team
→ Deployment branches: main only
</code></pre>
<p>The AWS workflow uses:</p>
<pre><code class="language-yaml">environment: production
</code></pre>
<p>That means GitHub pauses the AWS deployment until an approved reviewer allows it. This creates a real promotion gate instead of "every push deploys to prod."</p>
<h4 id="heading-3-prefer-fine-grained-tokens-or-a-github-app">3. Prefer fine-grained tokens or a GitHub App</h4>
<p>For the basic lab, <code>INFRA_REPO_TOKEN</code> can be a classic PAT. For production, tighten it.</p>
<p>Better option:</p>
<pre><code class="language-text">Fine-grained personal access token
→ Repository access: only YOUR_GITHUB_USERNAME/clearledger-infra
→ Permissions:
   Contents: Read and write
   Metadata: Read
</code></pre>
<p>Best option for teams: use a GitHub App installed only on <code>clearledger-infra</code>, with permission to write contents. That gives better audit logs and easier rotation than a personal token.</p>
<p>Store <code>INFRA_REPO_TOKEN</code> as a production environment secret, not a general repository secret:</p>
<pre><code class="language-text">clearledger
→ Settings
→ Environments
→ production
→ Environment secrets
→ INFRA_REPO_TOKEN
</code></pre>
<h4 id="heading-4-lock-aws-oidc-to-the-production-environment">4. Lock AWS OIDC to the production environment</h4>
<p>This isn't a shell command. It's a trust rule Terraform writes into AWS when you run <code>terraform apply</code>.</p>
<p>In <code>iam.tf</code>, the IAM role <code>clearledger-github-actions-ecr</code> only accepts GitHub tokens whose subject claim matches:</p>
<pre><code class="language-text">repo:YOUR_GITHUB_USERNAME/clearledger:environment:production
</code></pre>
<p>Only GitHub Actions jobs running in the <code>production</code> environment of your <code>clearledger</code> repo can assume the ECR push role. A random branch, fork, or workflow without that environment can't get AWS credentials.</p>
<p><strong>What you do:</strong></p>
<ol>
<li><p>Set <code>github_owner</code> in <code>terraform.tfvars</code>, then <code>terraform apply</code> (Stage 8 step 2).</p>
</li>
<li><p>On GitHub: <strong>Settings → Environments → production.</strong> Create it if missing, and add protection rules if you want.</p>
</li>
<li><p>Add environment secret <code>AWS_ACTIONS_ROLE_ARN</code> = <code>terraform output -raw github_actions_ecr_role_arn</code>.</p>
</li>
<li><p><code>ci-aws.yaml</code> already sets <code>environment: production</code> on the ECR jobs, that is what makes GitHub mint a matching token.</p>
</li>
</ol>
<p><strong>Verify the rule exists (optional):</strong></p>
<pre><code class="language-bash">aws iam get-role --role-name clearledger-github-actions-ecr \
  --query 'Role.AssumeRolePolicyDocument.Statement[0].Condition.StringEquals."token.actions.githubusercontent.com:sub"' \
  --output text
</code></pre>
<p>Expect: <code>repo:your-username/clearledger:environment:production</code></p>
<p>On the manual Stage 8 path you can skip GitHub CI entirely, this lock only matters when you enable CI, AWS (ECR + OIDC).</p>
<h4 id="heading-5-staging-before-production-promote-dont-rebuild">5. Staging before production (promote, don't rebuild)</h4>
<p><strong>Lab flow:</strong> push to <code>main</code> → <code>ci-aws.yaml</code> builds and scans → CI updates <code>stages/stage-8-aws-migration/manifests/kustomization.yaml</code> with the new image tag → Argo CD syncs <code>clearledger-aws</code>.</p>
<p>Homelab Stages 1–7 still use <code>clearledger-infra</code> and Docker Hub. Stage 8 AWS uses the in-repo kustomize path.</p>
<p>Real production adds a staging step in the middle: build the image once, deploy that same tag or digest to staging, run smoke tests or get manual approval, then promote to production, without building again.</p>
<p>Why? Because, if you rebuild for prod, you might ship different code than what passed staging. The safe pattern is one artifact, tested once, promoted twice.</p>
<pre><code class="language-text">Build once (one image SHA)
  → deploy to staging
  → test / approve
  → deploy the same SHA to production
</code></pre>
<h4 id="heading-6-use-private-networking-where-possible">6. Use private networking where possible</h4>
<p>For production AWS:</p>
<pre><code class="language-text">- EKS nodes in private subnets
- RDS in private subnets
- Private EKS API endpoint, or restricted public endpoint
- Security groups scoped to required ports only
- ALB public only if the app is public
- No SSH-based deployment path
</code></pre>
<p>The pipeline should talk to AWS APIs through IAM/OIDC and deploy through GitOps. It shouldn't SSH into EC2 instances.</p>
<h4 id="heading-7-store-terraform-state-remotely">7. Store Terraform state remotely</h4>
<p>Local Terraform state is fine for a lab. Production should use encrypted remote state:</p>
<pre><code class="language-text">- S3 bucket for terraform.tfstate
- DynamoDB table for state locking
- SSE encryption enabled
- Bucket versioning enabled
- Public access blocked
</code></pre>
<p>The Terraform backend block is already included in <code>stages/stage-8-aws-migration/terraform/main.tf</code> as a commented template.<br>Uncomment it after you create the S3 bucket and DynamoDB lock table.</p>
<h4 id="heading-production-ready-summary">Production-ready summary:</h4>
<pre><code class="language-text">- CI builds and proves the artifact.
- GitHub Environments approve production.
- OIDC gives short-lived AWS credentials.
- ECR stores immutable images.
- kustomization.yaml (Stage 8 path) records desired state.
- ArgoCD clearledger-aws deploys from Git.
- No SSH. No static AWS keys. No direct kubectl from CI.
</code></pre>
<p>Open the URL. ClearLedger is running on AWS. Same architecture, same security layers, just new infrastructure.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/3e6a283d-add1-467b-83da-97c1915f0b92.png" alt="screenshot of clearledger UI running on EKS with ALB URL" style="display: block;" width="1473" height="1269" loading="lazy">

<p><strong>Destroy when done.</strong> This stops all charges:</p>
<pre><code class="language-bash">make aws-down
</code></pre>
<p>See <code>stages/stage-8-aws-migration/README.md</code> for the full walkthrough and cost reference.</p>
<h3 id="heading-what-you-learned-in-stage-8">What You Learned in Stage 8</h3>
<ul>
<li><p>That containerized applications are portable: the same code runs on your laptop and on AWS</p>
</li>
<li><p>What Terraform does: declares infrastructure as code so environments are reproducible</p>
</li>
<li><p>What changes in a cloud migration (managed services, IAM, networking) and what does not (application code, CI logic, security policies)</p>
</li>
<li><p>Three AWS secret delivery paths: ESO (default), CSI file mounts (§8.5), vs Vault on homelab</p>
</li>
<li><p>AWS-specific security services: GuardDuty (threat detection), CloudTrail (API audit), GitHub Actions OIDC (pipeline AWS auth without long-lived keys), and IRSA (pod-level IAM without long-lived credentials)</p>
</li>
</ul>
<p><strong>What you can now put on your CV / say in an interview:</strong></p>
<blockquote>
<p>Migrated the same architecture to AWS (EKS, ECR, RDS, ALB, with secrets via External Secrets Operator and IRSA) provisioned by Terraform, without rewriting the application.</p>
</blockquote>
<p><strong>When you're done on AWS, tear down to stop charges:</strong></p>
<pre><code class="language-bash">make aws-down
</code></pre>
<p>Your homelab VM is separate. If you plan to return to it, you should already have a snapshot from Stage 7 (<code>make snapshots</code> to confirm). See <a href="#heading-how-to-save-your-progress">Saving your progress</a>.</p>
<h2 id="heading-troubleshooting-see-troubleshootingmd">Troubleshooting (See <a href="https://github.com/Osomudeya/clearledger/blob/main/docs/troubleshooting.md">troubleshooting.md</a>)</h2>
<p><strong>Pod stuck in Pending:</strong></p>
<pre><code class="language-bash">kubectl describe pod POD_NAME -n clearledger
# Insufficient memory/cpu → reduce resource requests
# Image pull error → check Docker Hub repo name and credentials
</code></pre>
<p><strong>Kyverno blocking a deployment:</strong></p>
<pre><code class="language-bash">kubectl get events -n clearledger --sort-by='.lastTimestamp' | tail -10
kubectl get policyreport -n clearledger -o yaml
</code></pre>
<p><strong>Vault agent not injecting secrets:</strong></p>
<pre><code class="language-bash">kubectl logs POD_NAME -n clearledger -c vault-agent-init
kubectl exec -n vault vault-0 -- vault read auth/kubernetes/role/auth-service
</code></pre>
<p><strong>Falco not firing alerts:</strong></p>
<pre><code class="language-bash">kubectl logs -n falco daemonset/falco | grep -i error | tail -20
</code></pre>
<p><strong>ArgoCD shows OutOfSync:</strong></p>
<pre><code class="language-bash">argocd app sync clearledger --force
argocd app get clearledger
kubectl get events -n clearledger --sort-by='.lastTimestamp'
</code></pre>
<p><strong>clearledger.local not resolving:</strong></p>
<pre><code class="language-bash">multipass info clearledger | grep IPv4
grep clearledger /etc/hosts
# If the IP changed, update /etc/hosts
</code></pre>
<p><strong>VM disk full or pods Evicted (disk pressure):</strong></p>
<pre><code class="language-bash">make doctor     # PASS / WARN / FAIL + PVC and Prometheus TSDB sizes
make reclaim    # safe reclaim — unused images + journald only (not PVCs)
</code></pre>
<p>If still FAIL after reclaim, tear down and recreate: <code>make teardown &amp;&amp; make setup</code>. Full guidance: <a href="https://github.com/Osomudeya/clearledger/blob/main/docs/troubleshooting.md">troubleshooting.md: disk health</a> and <a href="https://github.com/Osomudeya/clearledger/blob/main/docs/troubleshooting.md">VM disk full</a>.</p>
<h2 id="heading-compliance-reference">Compliance Reference</h2>
<p>Every control maps to at least one framework. Full mapping: <a href="compliance-mapping.md"><code>docs/compliance-mapping.md</code></a>.</p>
<table>
<thead>
<tr>
<th>Control</th>
<th>Tool</th>
<th>Stage</th>
<th>PCI-DSS</th>
<th>SOC2</th>
<th>CIS K8s</th>
</tr>
</thead>
<tbody><tr>
<td>Secrets detection</td>
<td>Gitleaks</td>
<td>3</td>
<td>6.2</td>
<td>CC8.1</td>
<td>—</td>
</tr>
<tr>
<td>SAST</td>
<td>Semgrep</td>
<td>3</td>
<td>6.3.2</td>
<td>CC7.1</td>
<td>—</td>
</tr>
<tr>
<td>Dependency scan</td>
<td>Trivy SCA</td>
<td>3</td>
<td>6.3.3</td>
<td>CC7.1</td>
<td>—</td>
</tr>
<tr>
<td>IaC scan</td>
<td>Checkov</td>
<td>3</td>
<td>6.3.1</td>
<td>CC6.1</td>
<td>—</td>
</tr>
<tr>
<td>Image signing</td>
<td>Cosign</td>
<td>3</td>
<td>6.3</td>
<td>CC6.1</td>
<td>—</td>
</tr>
<tr>
<td>SBOM generation</td>
<td>Syft</td>
<td>3</td>
<td>6.3.3</td>
<td>CC6.1</td>
<td>—</td>
</tr>
<tr>
<td>Non-root containers</td>
<td>Kyverno</td>
<td>4</td>
<td>6.5</td>
<td>CC6.3</td>
<td>5.2.6</td>
</tr>
<tr>
<td>Resource limits</td>
<td>Kyverno</td>
<td>4</td>
<td>—</td>
<td>A1.1</td>
<td>5.2.4</td>
</tr>
<tr>
<td>No privilege escalation</td>
<td>Kyverno</td>
<td>4</td>
<td>6.5</td>
<td>CC6.3</td>
<td>5.2.5</td>
</tr>
<tr>
<td>Secrets management</td>
<td>Vault</td>
<td>5</td>
<td>3.5</td>
<td>CC6.1</td>
<td>—</td>
</tr>
<tr>
<td>Runtime detection</td>
<td>Falco</td>
<td>6</td>
<td>10.7</td>
<td>CC7.2</td>
<td>—</td>
</tr>
<tr>
<td>Network segmentation</td>
<td>NetworkPolicy</td>
<td>6</td>
<td>1.3</td>
<td>CC6.6</td>
<td>5.3.2</td>
</tr>
<tr>
<td>Security observability</td>
<td>Grafana</td>
<td>7</td>
<td>10.6</td>
<td>CC7.2</td>
<td>—</td>
</tr>
<tr>
<td>DORA metrics</td>
<td>ArgoCD + Grafana</td>
<td>7</td>
<td>—</td>
<td>—</td>
<td>—</td>
</tr>
<tr>
<td>Account threat detection</td>
<td>GuardDuty</td>
<td>8</td>
<td>10.6</td>
<td>CC7.2</td>
<td>—</td>
</tr>
<tr>
<td>API audit trail</td>
<td>CloudTrail</td>
<td>8</td>
<td>10.2</td>
<td>CC7.3</td>
<td>—</td>
</tr>
</tbody></table>
<p><strong>EU DORA (Digital Operational Resilience Act):</strong> applies to EU financial entities since January 2025. ClearLedger maps to all five DORA pillars. Full mapping in <a href="https://github.com/Osomudeya/clearledger/blob/main/docs/compliance-mapping.md"><code>docs/compliance-mapping.md</code></a>.</p>
<h2 id="heading-interview-preparation">Interview Preparation</h2>
<p>Full weak/strong answers: <a href="https://github.com/Osomudeya/clearledger/blob/main/docs/interview-prep.md"><code>docs/interview-prep.md</code></a></p>
<p>Practice these as you finish each stage:</p>
<p><strong>Stage 0:</strong> How does traffic reach your services in Kubernetes? What breaks first when deployment is manual?</p>
<p><strong>Stage 1:</strong> How do you prove what image is deployed for a given commit? What stops a developer bypassing CI?</p>
<p><strong>Stage 2:</strong> What does GitOps mean mechanically? How do you prove drift is corrected automatically?</p>
<p><strong>Stage 3:</strong> Difference between SAST, IaC scanning, and image scanning? Where do you draw the line for fail-on severity?</p>
<p><strong>Stage 4:</strong> What is admission control and why is it different from CI? How would you safely introduce a policy exception?</p>
<p><strong>Stage 5:</strong> Why are Kubernetes Secrets not "secret management"? How do you rotate secrets with minimal downtime risk?</p>
<p><strong>Stage 6:</strong> What does runtime detection catch that CI and admission can't? What is your first response to a shell-spawn alert?</p>
<p><strong>Stage 7:</strong> What's the difference between a dashboard and an alert? How do you produce audit evidence, not just claims?</p>
<p><strong>Stage 8:</strong> What actually changes when you move to EKS? What shouldn't change? How does IRSA reduce risk?</p>
<h2 id="heading-aws-cost-reference">AWS Cost Reference</h2>
<p>Default Stage 8 sizes (eu-west-1, approximate):</p>
<table>
<thead>
<tr>
<th>Resource</th>
<th>Monthly (8h/day)</th>
<th>Monthly (24/7)</th>
</tr>
</thead>
<tbody><tr>
<td>EKS control plane</td>
<td>~$24</td>
<td>~$73</td>
</tr>
<tr>
<td>3× t3.medium nodes</td>
<td>~$30</td>
<td>~$92</td>
</tr>
<tr>
<td>NAT Gateway</td>
<td>~$11</td>
<td>~$33</td>
</tr>
<tr>
<td>RDS db.t3.micro</td>
<td>~$4</td>
<td>~$13</td>
</tr>
<tr>
<td>ALB</td>
<td>~$2</td>
<td>~$6</td>
</tr>
<tr>
<td>GuardDuty + CloudTrail</td>
<td>~$2</td>
<td>~$5</td>
</tr>
<tr>
<td><strong>Total estimate</strong></td>
<td><strong>~$73</strong></td>
<td><strong>~$222</strong></td>
</tr>
</tbody></table>
<p>Always destroy when not in use:</p>
<pre><code class="language-bash">make aws-down
</code></pre>
<h2 id="heading-conclusion">Conclusion</h2>
<p>You've now built a fintech application and layered eight security and reliability controls on top of it: all from a laptop.</p>
<p>You started with raw Kubernetes and manual deploys in Stage 0. You added a CI pipeline that builds, scans, and signs images automatically in Stage 1. You connected Git to the cluster with ArgoCD in Stage 2. You gated every push with SAST, IaC, and image scanning in Stage 3. You blocked bad workloads at the cluster boundary with Kyverno in Stage 4. You moved credentials out of Git and Kubernetes secrets into Vault in Stage 5. You added runtime threat detection with Falco and network segmentation in Stage 6. You built observability dashboards that produce audit evidence in Stage 7. And you migrated the whole thing to AWS in Stage 8.</p>
<p>None of these stages is a toy exercise. Each one represents a real problem that real teams hit in production. You felt the pain, then built the solution. That's the difference between reading about DevSecOps and being able to do it.</p>
<p>Take your screenshots, update your CV with the specific tools and outcomes, and use the interview prep section when you need to talk through the decisions you made. You built every one of them.</p>
<p><em>If you found this guide helpful, share it with someone breaking into DevOps or DevSecOps and</em> <a href="https://www.linkedin.com/in/osomudeya-zudonu-17290b124"><em>connect on LinkedIn</em></a><em>.</em></p>
<p><em>I also post DevOps walkthroughs and interview tips for getting hired; follow or</em> <a href="https://osomudeya.kit.com/23db7ca59f"><em>subscribe there</em></a> <em>if you want more.</em></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Multi-Tenant SaaS API with Node.js, RBAC, and Audit Logging ]]>
                </title>
                <description>
                    <![CDATA[ A colleague asked me to help debug what looked like a permissions issue in their SaaS project management tool. Users were seeing resources they hadn't created. I pulled up the query logs expecting som ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-multi-tenant-saas-api-with-nodejs-rbac-and-audit-logging/</link>
                <guid isPermaLink="false">6a5e8914bc397f89a942b88b</guid>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ api ]]>
                    </category>
                
                    <category>
                        <![CDATA[ PostgreSQL ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ backend ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Zia Ullah ]]>
                </dc:creator>
                <pubDate>Mon, 20 Jul 2026 20:46:12 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/69793fe0-fe0e-4c9c-839d-12a134f65287.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>A colleague asked me to help debug what looked like a permissions issue in their SaaS project management tool. Users were seeing resources they hadn't created.</p>
<p>I pulled up the query logs expecting something subtle. It was not. The list endpoint had no <code>tenant_id</code> filter at all. Every tenant in the database could read every other tenant's projects. The application never threw an error. It just returned whatever was there.</p>
<p>Missing tenant filters don't throw errors. They return the wrong data without any complaint, and nothing in your logs will flag it. I've seen this run in production for weeks before a support ticket pointed anyone at the query logs.</p>
<p>When it does surface, who finds it first matters a lot. A customer noticing it is bad. A compliance auditor noticing it during a SOC 2 review is a different kind of problem.</p>
<p>Isolation built in from the start is a day of work. The time I spent helping a team retrofit it after a compliance review was considerably longer than that, and involved more customer emails than anyone wanted to write.</p>
<p>The stack is Node.js with PostgreSQL. CRUD is the easy part. Tenant isolation, RBAC, and audit logging take more care, and where those checks run in the stack matters. I put all three in middleware, before any route handler fires. A handler that never calls the isolation logic directly can't accidentally skip it.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ul>
<li><p>Node.js 18+</p>
</li>
<li><p>PostgreSQL 14+</p>
</li>
<li><p>Basic knowledge of Express.js and JWT</p>
</li>
</ul>
<h2 id="heading-what-we-will-build">What We Will Build</h2>
<p>A multi-tenant Express REST API that enforces:</p>
<ol>
<li><p><strong>Tenant isolation:</strong> every database query scopes to the <code>tenant_id</code> from the verified JWT. The client can't influence which tenant the query runs against.</p>
</li>
<li><p><strong>RBAC:</strong> four roles, each with a numeric level (SuperAdmin is highest, Viewer lowest). Middleware checks the level before the handler runs.</p>
</li>
<li><p><strong>Audit logging:</strong> any write or sensitive read appends a row to the audit table. The app can't modify those rows afterward. The database enforces this directly. If a bug in the app tries to UPDATE an audit row, the database refuses it. Application-level enforcement alone can't give you that guarantee.</p>
</li>
<li><p><strong>Per-tenant rate limiting:</strong> request counts in Redis, keyed to the tenant. I've seen IP-based limiting break an enterprise rollout when fifty users came through a single corporate proxy.</p>
</li>
<li><p><strong>Tenant isolation tests:</strong> a dedicated test file that proves cross-tenant data can't leak. Wire it into CI and it catches broken isolation before it ships.</p>
</li>
</ol>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-how-multi-tenancy-works">How Multi-Tenancy Works</a></p>
</li>
<li><p><a href="#heading-architecture-overview">Architecture Overview</a></p>
</li>
<li><p><a href="#heading-database-schema-design">Database Schema Design</a></p>
</li>
<li><p><a href="#heading-project-setup">Project Setup</a></p>
</li>
<li><p><a href="#heading-jwt-design-for-multi-tenancy">JWT Design for Multi-Tenancy</a></p>
</li>
<li><p><a href="#heading-auth-and-rbac-middleware">Auth and RBAC Middleware</a></p>
</li>
<li><p><a href="#heading-the-tenant-safe-repository-layer">The Tenant-Safe Repository Layer</a></p>
</li>
<li><p><a href="#heading-audit-logging-service">Audit Logging Service</a></p>
</li>
<li><p><a href="#heading-per-tenant-rate-limiting">Per-Tenant Rate Limiting</a></p>
</li>
<li><p><a href="#heading-building-the-routes">Building the Routes</a></p>
</li>
<li><p><a href="#heading-testing-tenant-isolation">Testing Tenant Isolation</a></p>
</li>
<li><p><a href="#heading-troubleshooting">Troubleshooting</a></p>
</li>
<li><p><a href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
</ol>
<h2 id="heading-how-multi-tenancy-works">How Multi-Tenancy Works</h2>
<p>This tutorial uses a <strong>shared database with row-level isolation</strong>: a <code>tenant_id</code> column on every table, a filter on every query. The database holds everyone's data together. The application decides what each tenant can see.</p>
<p>Two other approaches exist: schema-per-tenant and database-per-tenant. I've talked to teams on schema-per-tenant who ended up spending more engineering time on migration tooling than on their actual product. Database-per-tenant gives stronger guarantees but a connection pool that balloons with every new customer signup.</p>
<p>Neither scales cheaply. Row-level isolation scales further than most teams expect. The ones I know who moved off it did so years in, usually under specific regulatory pressure, not because the approach stopped working.</p>
<p>The one thing in this design that can't be optional: <code>tenant_id</code> <strong>must always come from the verified JWT.</strong> Not from the request body, not from the URL. Users control what they put in both of those. They don't control what gets signed into a JWT on your server.</p>
<h2 id="heading-architecture-overview">Architecture Overview</h2>
<pre><code class="language-plaintext">HTTP Request
     │
     ▼
┌─────────────────────────────────────────┐
│           Express Middleware Stack       │
│                                         │
│  1. Rate Limiter (per tenant_id)        │
│  2. Auth Middleware (verify JWT)        │
│     └─► Extracts: userId, tenantId,    │
│          role, permissions              │
│  3. RBAC Middleware (check role)        │
└──────────────┬──────────────────────────┘
               │
               ▼
┌─────────────────────────────────────────┐
│           Route Handler                  │
│                                         │
│  1. Call Repository (tenant-safe query) │
│  2. Call Audit Service (fire &amp; forget)  │
│  3. Return response                     │
└──────────────┬──────────────────────────┘
               │
     ┌─────────┴──────────┐
     ▼                    ▼
┌─────────┐        ┌────────────┐
│ Projects│        │ Audit Logs │
│  Table  │        │   Table    │
│(+tenant)│        │(append only│
└─────────┘        └────────────┘
</code></pre>
<p>Rate limiting, auth, and RBAC all run before any handler sees the request. Writes pass through the audit service. The repository takes <code>tenantId</code> from <code>req.user</code> and the handler never touches tenant scoping directly, so there's no path around it.</p>
<h2 id="heading-database-schema-design">Database Schema Design</h2>
<pre><code class="language-sql">-- Tenants table
CREATE TABLE tenants (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name        VARCHAR(255) NOT NULL,
  plan        VARCHAR(50) NOT NULL DEFAULT 'free', -- 'free', 'pro', 'enterprise'
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Users table
CREATE TABLE users (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  email       VARCHAR(255) NOT NULL,
  role        VARCHAR(50) NOT NULL DEFAULT 'Member', -- 'SuperAdmin','TenantAdmin','Member','Viewer'
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  UNIQUE(tenant_id, email)
);

CREATE INDEX idx_users_tenant ON users(tenant_id);

-- Projects table (example resource — replace with your domain entity)
CREATE TABLE projects (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  name        VARCHAR(255) NOT NULL,
  description TEXT,
  created_by  UUID NOT NULL REFERENCES users(id),
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_projects_tenant ON projects(tenant_id);

-- Audit log table (append-only — never UPDATE or DELETE rows here)
CREATE TABLE audit_logs (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   UUID NOT NULL,
  user_id     UUID NOT NULL,
  user_email  TEXT NOT NULL,
  user_role   TEXT NOT NULL,        -- role at time of action
  action      TEXT NOT NULL,        -- 'CREATE', 'UPDATE', 'DELETE', 'VIEW'
  resource    TEXT NOT NULL,        -- table name
  resource_id TEXT,
  old_values  JSONB,
  new_values  JSONB,
  ip_address  INET,
  user_agent  TEXT,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_audit_tenant ON audit_logs(tenant_id);
CREATE INDEX idx_audit_created ON audit_logs(created_at DESC);

-- Protect audit log at database level
-- Use a DO block so this runs safely in Docker where app_user is the superuser
DO $$
BEGIN
  IF current_user &lt;&gt; 'app_user' THEN
    REVOKE DELETE, UPDATE ON audit_logs FROM app_user;
  END IF;
END $$;
</code></pre>
<p>The <code>REVOKE</code> matters. Application bugs happen. If something in your codebase accidentally tries to UPDATE an audit row, you want the database to refuse it outright, not silently comply.</p>
<h2 id="heading-project-setup">Project Setup</h2>
<pre><code class="language-bash">mkdir nodejs-multitenant-saas-api
cd nodejs-multitenant-saas-api
npm init -y
npm install express pg jsonwebtoken bcryptjs express-rate-limit rate-limit-redis ioredis dotenv
npm install --save-dev jest supertest
</code></pre>
<h3 id="heading-starting-postgresql-and-redis-with-docker">Starting PostgreSQL and Redis with Docker</h3>
<p>Skip the local installs. One <code>docker-compose.yml</code> in the project root brings up both PostgreSQL and Redis:</p>
<pre><code class="language-yaml">services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: saas_api
      POSTGRES_USER: app_user
      POSTGRES_PASSWORD: app_password
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./schema.sql:/docker-entrypoint-initdb.d/01_schema.sql

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

volumes:
  postgres_data:
</code></pre>
<p>That <code>schema.sql</code> mount runs your SQL automatically when the container first starts. No psql required.</p>
<pre><code class="language-bash">docker compose up -d
</code></pre>
<p><code>.env</code> in the project root:</p>
<pre><code class="language-plaintext">DATABASE_URL=postgresql://app_user:app_password@localhost:5432/saas_api
REDIS_URL=redis://localhost:6379
JWT_SECRET=your_random_secret_here
PORT=3000
NODE_ENV=development
</code></pre>
<p>Don't type a <code>JWT_SECRET</code> by hand. Run this to generate one:</p>
<pre><code class="language-bash">node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
</code></pre>
<p>File structure:</p>
<pre><code class="language-plaintext">nodejs-multitenant-saas-api/
├── src/
│   ├── middleware/
│   │   ├── auth.js          # JWT verification + tenant extraction
│   │   ├── rbac.js          # Role enforcement
│   │   └── rateLimiter.js   # Per-tenant rate limiting
│   ├── services/
│   │   └── auditService.js  # Append-only audit logger
│   ├── repositories/
│   │   └── projectRepo.js   # Tenant-safe DB queries
│   ├── routes/
│   │   └── projects.js      # Route handlers
│   └── utils/
│       └── token.js         # JWT token generation
├── db/
│   ├── index.js             # PostgreSQL pool
│   └── redis.js             # Redis client
├── docker-compose.yml
├── app.js
├── server.js
└── tests/
    └── tenantIsolation.test.js
</code></pre>
<h3 id="heading-boilerplate-files">Boilerplate Files</h3>
<p>There are four files the tutorial doesn't cover in detail, but the test file needs all of them to run:</p>
<pre><code class="language-javascript">// db/index.js
const { Pool } = require('pg');

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

pool.on('error', (err) =&gt; console.error('PostgreSQL error:', err.message));

module.exports = { pool };
</code></pre>
<pre><code class="language-javascript">// db/redis.js
const Redis = require('ioredis');

const redisClient = new Redis(process.env.REDIS_URL);

redisClient.on('error', (err) =&gt; console.error('Redis error:', err.message));

module.exports = { redisClient };
</code></pre>
<pre><code class="language-javascript">// app.js
require('dotenv').config();
const express = require('express');
const projectsRouter = require('./src/routes/projects');

const app = express();
app.use(express.json());

app.use('/api/projects', projectsRouter);

// Global error handler — must have 4 parameters to be recognised by Express
app.use((err, req, res, next) =&gt; {
  console.error(err.stack);
  res.status(500).json({ error: 'Internal server error' });
});

module.exports = app;
</code></pre>
<pre><code class="language-javascript">// server.js
const app = require('./app');

const PORT = process.env.PORT || 3000;
app.listen(PORT, () =&gt; console.log(`Server running on port ${PORT}`));
</code></pre>
<p><code>bcryptjs</code> is included for a login endpoint with proper password hashing. That part isn't covered here, but the GitHub repo has a working <code>/api/auth/login</code> example.</p>
<h2 id="heading-jwt-design-for-multi-tenancy">JWT Design for Multi-Tenancy</h2>
<p>Both <code>tenantId</code> and <code>role</code> go into the JWT payload. Everything downstream reads from these two fields. Get them wrong, and nothing behaves correctly.</p>
<pre><code class="language-javascript">// Example JWT payload
{
  "userId": "usr_abc123",
  "tenantId": "ten_xyz789",
  "email": "alice@acme.com",
  "role": "TenantAdmin",
  "iat": 1720000000,
  "exp": 1720086400
}
</code></pre>
<p>The roles in order of privilege:</p>
<ul>
<li><p><strong>SuperAdmin:</strong> cross-tenant access for your internal team only</p>
</li>
<li><p><strong>TenantAdmin:</strong> full access within their tenant</p>
</li>
<li><p><strong>Member:</strong> read and write within their tenant</p>
</li>
<li><p><strong>Viewer:</strong> read-only within their tenant</p>
</li>
</ul>
<p>Generate a token (used for testing and your auth endpoint):</p>
<pre><code class="language-javascript">// src/utils/token.js
const jwt = require('jsonwebtoken');

function generateToken({ userId, tenantId, email, role }) {
  return jwt.sign(
    { userId, tenantId, email, role },
    process.env.JWT_SECRET,
    { expiresIn: '24h' }
  );
}

module.exports = { generateToken };
</code></pre>
<h2 id="heading-auth-and-rbac-middleware">Auth and RBAC Middleware</h2>
<p>The auth middleware does two things: verifies the JWT signature and extracts the tenant context into <code>req.user</code>.</p>
<p>That second part is what the entire system depends on. Every query downstream reads <code>req.user.tenantId</code>. The client has no say in what that value is. They send a token the server signed, and the server reads back what it put in.</p>
<pre><code class="language-javascript">// src/middleware/auth.js
const jwt = require('jsonwebtoken');

function authMiddleware(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing or malformed Authorization header' });
  }

  const token = authHeader.split(' ')[1];

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);

    // tenantId always comes from the verified token — never req.body or req.params
    req.user = {
      userId:   decoded.userId,
      tenantId: decoded.tenantId,
      email:    decoded.email,
      role:     decoded.role,
    };

    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}

module.exports = { authMiddleware };
</code></pre>
<p>The RBAC middleware is separate from auth by design. Auth runs on every route. Role enforcement only applies where a minimum role is required. You pass the allowed roles to <code>requireRole()</code> and it compares the user's level against the hierarchy. A Viewer trying to delete something hits the 403 before the handler ever runs.</p>
<pre><code class="language-javascript">// src/middleware/rbac.js
const ROLE_HIERARCHY = {
  SuperAdmin:   4,
  TenantAdmin:  3,
  Member:       2,
  Viewer:       1,
};

// requireRole('TenantAdmin') — user must be TenantAdmin or higher
function requireRole(...roles) {
  return (req, res, next) =&gt; {
    const userLevel = ROLE_HIERARCHY[req.user?.role] ?? 0;
    const requiredLevel = Math.min(...roles.map(r =&gt; ROLE_HIERARCHY[r] ?? 999));

    if (userLevel &lt; requiredLevel) {
      return res.status(403).json({
        error: 'Insufficient permissions',
        required: roles,
        current: req.user?.role,
      });
    }

    next();
  };
}

module.exports = { requireRole };
</code></pre>
<h2 id="heading-the-tenant-safe-repository-layer">The Tenant-Safe Repository Layer</h2>
<p>Isolation lives here. Every function takes <code>tenantId</code> as a required argument, pulled from <code>req.user</code> by the handler. There's no way to call these without providing a tenant scope. I've watched teams try to handle this with a URL parameter instead (<code>GET /api/projects?tenantId=xyz</code>) and call it isolated. It is not. Any client sends whatever it wants in a query string.</p>
<pre><code class="language-javascript">// src/repositories/projectRepo.js
const { pool } = require('../../db');

// List all projects for a tenant — tenantId is ALWAYS from the JWT
async function listProjects(tenantId) {
  const result = await pool.query(
    `SELECT id, name, description, created_by, created_at
     FROM projects
     WHERE tenant_id = $1
     ORDER BY created_at DESC`,
    [tenantId]
  );
  return result.rows;
}

// Get a single project — returns null if it belongs to a different tenant
// NOTE: Returns 404 (not 403) intentionally — don't reveal the resource exists
async function getProject(id, tenantId) {
  const result = await pool.query(
    `SELECT id, name, description, created_by, created_at
     FROM projects
     WHERE id = $1 AND tenant_id = $2`,
    [id, tenantId]
  );
  return result.rows[0] || null;
}

async function createProject({ tenantId, name, description, createdBy }) {
  const result = await pool.query(
    `INSERT INTO projects (tenant_id, name, description, created_by)
     VALUES ($1, $2, $3, $4)
     RETURNING *`,
    [tenantId, name, description, createdBy]
  );
  return result.rows[0];
}

async function updateProject(id, tenantId, updates) {
  const result = await pool.query(
    `UPDATE projects
     SET name = COALESCE($3, name),
         description = COALESCE($4, description),
         updated_at = NOW()
     WHERE id = $1 AND tenant_id = $2
     RETURNING *`,
    [id, tenantId, updates.name, updates.description]
  );
  return result.rows[0] || null;
}

async function deleteProject(id, tenantId) {
  const result = await pool.query(
    `DELETE FROM projects WHERE id = $1 AND tenant_id = $2 RETURNING id`,
    [id, tenantId]
  );
  return result.rows[0] || null;
}

module.exports = { listProjects, getProject, createProject, updateProject, deleteProject };
</code></pre>
<p>Notice what <code>getProject</code> does when Tenant A tries to fetch a Tenant B resource. The query runs with Tenant A's <code>tenantId</code>. The condition <code>id = $1 AND tenant_id = $2</code> matches nothing, <code>null</code> comes back, and the handler sends a <code>404</code>. Not a <code>403</code>. A 403 tells the caller the resource exists, but they can't access it, which is information they shouldn't have.</p>
<h2 id="heading-audit-logging-service">Audit Logging Service</h2>
<pre><code class="language-javascript">// src/services/auditService.js
const { pool } = require('../../db');

async function log({
  tenantId,
  userId,
  userEmail,
  userRole,          // role at time of action — roles change, log should not
  action,            // 'CREATE' | 'UPDATE' | 'DELETE' | 'VIEW'
  resource,          // table name
  resourceId = null,
  oldValues = null,
  newValues = null,
  ipAddress = null,
  userAgent = null,
}) {
  const query = `
    INSERT INTO audit_logs
      (tenant_id, user_id, user_email, user_role, action, resource,
       resource_id, old_values, new_values, ip_address, user_agent)
    VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
  `;

  const values = [
    tenantId, userId, userEmail, userRole, action, resource,
    resourceId,
    oldValues  ? JSON.stringify(oldValues)  : null,
    newValues  ? JSON.stringify(newValues)  : null,
    ipAddress,
    userAgent,
  ];

  // Fire-and-forget — audit logging must never block or fail a user request
  pool.query(query, values).catch((err) =&gt; {
    console.error('[AuditService] Failed to write log:', err.message);
  });
}

module.exports = { log };
</code></pre>
<p>Capturing <code>userRole</code> at write time matters more than it looks. User roles change after the fact: someone gets demoted, a permission is revoked. If the log only records the user ID, you lose the context of what privilege they held when the action happened. Store the role at the time of the action, and you always know.</p>
<h2 id="heading-per-tenant-rate-limiting">Per-Tenant Rate Limiting</h2>
<p>IP-based rate limiting breaks down in SaaS. A corporate customer might route hundreds of users through a single NAT gateway, sharing one IP address. One heavy tenant throttles everyone else on that address.</p>
<p>I've watched teams discover this the hard way when an enterprise customer suddenly floods the API, and their other tenants start getting 429s with no explanation. Scope limits to <code>tenant_id</code> instead.</p>
<pre><code class="language-javascript">// src/middleware/rateLimiter.js
const rateLimit = require('express-rate-limit');
const { RedisStore } = require('rate-limit-redis');
const { redisClient } = require('../../db/redis');

// Rate limits by plan — extend as needed
const PLAN_LIMITS = {
  free:       { max: 100,  windowMs: 15 * 60 * 1000 }, // 100 req / 15 min
  pro:        { max: 500,  windowMs: 15 * 60 * 1000 }, // 500 req / 15 min
  enterprise: { max: 2000, windowMs: 15 * 60 * 1000 }, // 2000 req / 15 min
};

function createTenantRateLimiter(plan = 'free') {
  const limits = PLAN_LIMITS[plan] || PLAN_LIMITS.free;

  return rateLimit({
    windowMs: limits.windowMs,
    max: limits.max,
    // Key = tenant_id from verified JWT — NOT the IP address
    keyGenerator: (req) =&gt; `tenant:${req.user?.tenantId || req.ip}`,
    store: new RedisStore({
      sendCommand: (...args) =&gt; redisClient.call(...args),
    }),
    handler: (req, res) =&gt; {
      res.status(429).json({
        error: 'Too many requests',
        retryAfter: Math.ceil(limits.windowMs / 1000),
      });
    },
  });
}

// Default limiter for all API routes
const defaultLimiter = createTenantRateLimiter('free');

module.exports = { defaultLimiter, createTenantRateLimiter };
</code></pre>
<h2 id="heading-building-the-routes">Building the Routes</h2>
<p>This is where everything connects. Auth and rate limiting apply to the whole router. Role checks go on individual routes. The audit log fires after every write. <code>tenantId</code> never comes from the request body or URL. <code>req.user.tenantId</code> is the only source, set by the auth middleware from the verified token, so there's no path around it.</p>
<p>One practical detail for Express 4: it doesn't catch async errors automatically. Every handler wraps its logic in try/catch and passes failures to <code>next(err)</code>. Skip that and an unhandled promise rejection returns a blank 500 with no log entry and no audit trail. The comment at the top of the router is a reminder that the pattern is intentional.</p>
<pre><code class="language-javascript">// src/routes/projects.js
const express = require('express');
const { authMiddleware }  = require('../middleware/auth');
const { requireRole }     = require('../middleware/rbac');
const { defaultLimiter }  = require('../middleware/rateLimiter');
const audit               = require('../services/auditService');
const repo                = require('../repositories/projectRepo');

const router = express.Router();

// All routes require authentication
router.use(authMiddleware);
router.use(defaultLimiter);

// Express 4 does not catch async errors automatically.
// Every handler must wrap await calls in try/catch and pass errors to next().
// Without this, an unhandled promise rejection silently returns 500
// with no useful message and no audit log entry.

// GET /api/projects — list all (Viewer and above)
router.get('/', async (req, res, next) =&gt; {
  try {
    const projects = await repo.listProjects(req.user.tenantId);

    audit.log({
      tenantId:   req.user.tenantId,
      userId:     req.user.userId,
      userEmail:  req.user.email,
      userRole:   req.user.role,
      action:     'VIEW',
      resource:   'projects',
      ipAddress:  req.ip,
      userAgent:  req.headers['user-agent'],
    });

    res.json(projects);
  } catch (err) {
    next(err);
  }
});

// GET /api/projects/:id — single project (Viewer and above)
router.get('/:id', async (req, res, next) =&gt; {
  try {
    const project = await repo.getProject(req.params.id, req.user.tenantId);
    if (!project) return res.status(404).json({ error: 'Not found' });
    res.json(project);
  } catch (err) {
    next(err);
  }
});

// POST /api/projects — create (Member and above)
router.post('/', requireRole('Member', 'TenantAdmin', 'SuperAdmin'), async (req, res, next) =&gt; {
  try {
    const { name, description } = req.body;
    if (!name) return res.status(400).json({ error: 'name is required' });

    const project = await repo.createProject({
      tenantId:    req.user.tenantId,
      name,
      description,
      createdBy:   req.user.userId,
    });

    audit.log({
      tenantId:    req.user.tenantId,
      userId:      req.user.userId,
      userEmail:   req.user.email,
      userRole:    req.user.role,
      action:      'CREATE',
      resource:    'projects',
      resourceId:  project.id,
      newValues:   project,
      ipAddress:   req.ip,
      userAgent:   req.headers['user-agent'],
    });

    res.status(201).json(project);
  } catch (err) {
    next(err);
  }
});

// PUT /api/projects/:id — update (Member and above)
router.put('/:id', requireRole('Member', 'TenantAdmin', 'SuperAdmin'), async (req, res, next) =&gt; {
  try {
    const oldProject = await repo.getProject(req.params.id, req.user.tenantId);
    if (!oldProject) return res.status(404).json({ error: 'Not found' });

    const updated = await repo.updateProject(req.params.id, req.user.tenantId, req.body);

    audit.log({
      tenantId:    req.user.tenantId,
      userId:      req.user.userId,
      userEmail:   req.user.email,
      userRole:    req.user.role,
      action:      'UPDATE',
      resource:    'projects',
      resourceId:  req.params.id,
      oldValues:   oldProject,
      newValues:   updated,
      ipAddress:   req.ip,
      userAgent:   req.headers['user-agent'],
    });

    res.json(updated);
  } catch (err) {
    next(err);
  }
});

// DELETE /api/projects/:id — TenantAdmin and above only
router.delete('/:id', requireRole('TenantAdmin', 'SuperAdmin'), async (req, res, next) =&gt; {
  try {
    const project = await repo.getProject(req.params.id, req.user.tenantId);
    if (!project) return res.status(404).json({ error: 'Not found' });

    await repo.deleteProject(req.params.id, req.user.tenantId);

    audit.log({
      tenantId:    req.user.tenantId,
      userId:      req.user.userId,
      userEmail:   req.user.email,
      userRole:    req.user.role,
      action:      'DELETE',
      resource:    'projects',
      resourceId:  req.params.id,
      oldValues:   project,
      ipAddress:   req.ip,
      userAgent:   req.headers['user-agent'],
    });

    res.json({ deleted: true });
  } catch (err) {
    next(err);
  }
});

module.exports = router;
</code></pre>
<h2 id="heading-testing-tenant-isolation">Testing Tenant Isolation</h2>
<p>Skip the isolation tests and you're flying blind. The application keeps running, nothing throws an error, but two customers are reading each other's data.</p>
<p>I've watched this sit undetected in production for months because nothing actually broke. The wrong data just showed up quietly. Automated tests on every pull request are the only reliable way to catch it early.</p>
<pre><code class="language-javascript">// tests/tenantIsolation.test.js
require('dotenv').config();  // must be first — loads DATABASE_URL and REDIS_URL
const request = require('supertest');
const app     = require('../app');
const { generateToken } = require('../src/utils/token');
const { pool }        = require('../db');
const { redisClient } = require('../db/redis');

// Test fixture: two isolated tenants, one project in Tenant B
async function seedTestData() {
  // Clean up from any previous run to avoid unique-constraint failures
  await pool.query(`DELETE FROM projects WHERE name LIKE 'TEST-%'`);
  await pool.query(`DELETE FROM tenants WHERE name IN ('Tenant A', 'Tenant B')`);

  const tenantA = (await pool.query(
    `INSERT INTO tenants (name, plan) VALUES ('Tenant A', 'pro') RETURNING id`
  )).rows[0].id;

  const tenantB = (await pool.query(
    `INSERT INTO tenants (name, plan) VALUES ('Tenant B', 'pro') RETURNING id`
  )).rows[0].id;

  const userA = (await pool.query(
    `INSERT INTO users (tenant_id, email, role) VALUES ($1, 'usera@a.com', 'Member') RETURNING id`,
    [tenantA]
  )).rows[0].id;

  // userB owns the project in Tenant B — satisfies the created_by FK constraint
  const userB = (await pool.query(
    `INSERT INTO users (tenant_id, email, role) VALUES ($1, 'userb@b.com', 'Member') RETURNING id`,
    [tenantB]
  )).rows[0].id;

  const projectB = (await pool.query(
    `INSERT INTO projects (tenant_id, name, created_by)
     VALUES ($1, 'TEST-Secret Project', $2) RETURNING id`,
    [tenantB, userB]
  )).rows[0].id;

  return { tenantA, tenantB, userA, projectB };
}

describe('Tenant Isolation', () =&gt; {
  let data;

  beforeAll(async () =&gt; {
    data = await seedTestData();
  });

  afterAll(async () =&gt; {
    await pool.query(`DELETE FROM tenants WHERE name IN ('Tenant A', 'Tenant B')`);
    await pool.end();
    await redisClient.quit();  // close Redis connection so Jest exits cleanly
  });

  test('Tenant A user cannot read Tenant B project', async () =&gt; {
    const token = generateToken({
      userId:   data.userA,
      tenantId: data.tenantA,   // ← Tenant A token
      email:    'usera@a.com',
      role:     'Member',
    });

    const res = await request(app)
      .get(`/api/projects/${data.projectB}`)  // ← Tenant B's project ID
      .set('Authorization', `Bearer ${token}`);

    // Must be 404, not 200 or 403
    expect(res.status).toBe(404);
  });

  test('Tenant A user cannot list Tenant B projects', async () =&gt; {
    const token = generateToken({
      userId:   data.userA,
      tenantId: data.tenantA,
      email:    'usera@a.com',
      role:     'TenantAdmin',
    });

    const res = await request(app)
      .get('/api/projects')
      .set('Authorization', `Bearer ${token}`);

    expect(res.status).toBe(200);
    // Response must contain zero Tenant B projects
    const names = res.body.map(p =&gt; p.name);
    expect(names).not.toContain('TEST-Secret Project');
  });

  test('Viewer cannot delete a project', async () =&gt; {
    const token = generateToken({
      userId:   data.userA,
      tenantId: data.tenantA,
      email:    'usera@a.com',
      role:     'Viewer',         // ← Viewer role
    });

    const res = await request(app)
      .delete(`/api/projects/${data.projectB}`)
      .set('Authorization', `Bearer ${token}`);

    expect(res.status).toBe(403);
  });
});
</code></pre>
<p>Run the tests:</p>
<pre><code class="language-bash">npm test
</code></pre>
<p>Three tests, three boundaries confirmed. Wire these into CI so they run on every pull request. A future refactor that quietly drops the <code>tenant_id</code> filter will get caught before it ships.</p>
<h2 id="heading-troubleshooting">Troubleshooting</h2>
<h3 id="heading-tenant-a-can-see-tenant-bs-data">Tenant A can see Tenant B's data</h3>
<p>One query is missing the <code>AND tenant_id = $N</code> clause. Search every repository file for <code>SELECT</code> statements and check each one. It's almost always this.</p>
<h3 id="heading-403-forbidden-on-a-route-that-should-be-accessible"><code>403 Forbidden</code> on a route that should be accessible</h3>
<p>The role string in the JWT doesn't match what <code>requireRole()</code> is checking. Check the exact string in the token payload. <code>'member'</code> and <code>'Member'</code> aren't the same thing. Paste your token into jwt.io and look at the role field directly.</p>
<h3 id="heading-rate-limiter-isnt-working">Rate limiter isn't working</h3>
<p>Redis is probably not connected. Log <code>redisClient.status</code> before the server starts. If it's not <code>ready</code>, the limiter has fallen back to in-memory, which means restarts reset all counters and tenant-scoped limiting stops working.</p>
<h3 id="heading-audit-log-table-growing-very-large">Audit log table growing very large</h3>
<p>Expected behaviour. Audit tables grow, that's the point. Once it gets large, ship rows older than a year to S3 or Azure Blob and keep querying against a smaller hot table. Most compliance requirements want at least 12 months of accessible logs anyway. Just don't DELETE from the table itself.</p>
<h3 id="heading-jwtverify-throws-jsonwebtokenerror-invalid-signature"><code>jwt.verify</code> throws <code>JsonWebTokenError: invalid signature</code></h3>
<p>The secret that signed the token doesn't match <code>JWT_SECRET</code> in the environment where you're verifying it. This comes up most when switching between environments or when a second service has a different value in its <code>.env</code>. Every service that calls <code>jwt.verify</code> needs the exact same secret. Copy it across, don't retype it.</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>The system you've built: row-level isolation in the repository, role checks before the handler runs, an audit table the app can't touch, and rate limits per tenant. That's the whole thing.</p>
<p>The tests are what I see dropped most often. Teams build the isolation, ship it, and never write something that actually proves cross-tenant data can't leak. Then a query gets refactored six months later and the <code>tenant_id</code> filter quietly disappears. CI catches it. Manual code review rarely does.</p>
<p>Schema-per-tenant comes up eventually if your product grows large enough. But not at the start. Row-level isolation handles more scale than most teams will ever hit, and it costs a fraction of the operational overhead.</p>
<p>The full working code is available on GitHub: <a href="https://github.com/ziaongit/nodejs-multitenant-saas-api">nodejs-multitenant-saas-api</a></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Manage Secrets Securely with Azure Key Vault in Node.js ]]>
                </title>
                <description>
                    <![CDATA[ Last year a client called me about exactly this. Someone ran git log -p on a hunch and found a .env committed two years earlier, never caught. Database password, Stripe secret, JWT signing key — all s ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-manage-secrets-securely-with-azure-key-vault-in-node-js/</link>
                <guid isPermaLink="false">6a5e27b295e748bed9510853</guid>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Azure ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Zia Ullah ]]>
                </dc:creator>
                <pubDate>Mon, 20 Jul 2026 13:50:42 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/5491b408-9c6b-4d4d-a53e-215119fb2d97.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Last year a client called me about exactly this. Someone ran <code>git log -p</code> on a hunch and found a <code>.env</code> committed two years earlier, never caught. Database password, Stripe secret, JWT signing key — all still active. All still in production.</p>
<p>IBM's 2024 breach cost report put the average data breach at <strong>$4.88 million</strong> — and that's the average, not the worst cases.</p>
<p>Exposed credentials are consistently near the top of root causes. GitHub found over a million secrets leaked in public repos in 2023 alone, before you even count the private ones nobody ever discovered.</p>
<p>It's not a people problem. The developers I've worked with aren't careless — the architecture is just set up to fail them. A <code>.env</code> file gets committed once by accident. Credentials get copied and pasted into a Slack message to unblock a teammate. A Docker image gets published with secrets baked into a layer. A server gets shut down, and nobody rotates the credentials it was holding.</p>
<p>Azure Key Vault solves this differently. Your application fetches credentials at runtime from a centralized, encrypted service — the <code>.env</code> file stops being a liability because it stops holding anything worth stealing.</p>
<p>What you'll build is a Node.js Express API that fetches every secret from Azure Key Vault at startup. No passwords in the code. When someone quits, there's nothing in the repo to rotate. The <code>.env</code> ends up with one line — the vault name.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ul>
<li><p>Node.js 18+</p>
</li>
<li><p>An Azure account (free tier works)</p>
</li>
<li><p>Azure CLI installed and logged in (<code>az login</code>)</p>
</li>
<li><p>Basic knowledge of Express.js</p>
</li>
<li><p>Docker (optional — only needed for the local database test section)</p>
</li>
</ul>
<h2 id="heading-what-we-will-build">What We Will Build</h2>
<p>A Node.js Express API that:</p>
<ol>
<li><p>Connects to PostgreSQL using credentials fetched from Key Vault at startup</p>
</li>
<li><p>Uses Managed Identity for authentication — no client secrets or passwords anywhere</p>
</li>
<li><p>Caches secrets in memory, so Key Vault isn't called on every request</p>
</li>
<li><p>Works locally via Azure CLI auth and in production via Managed Identity — same code, zero changes</p>
</li>
</ol>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-how-the-architecture-works">How the Architecture Works</a></p>
</li>
<li><p><a href="#heading-what-is-azure-key-vault">What Is Azure Key Vault?</a></p>
</li>
<li><p><a href="#heading-set-up-the-key-vault">Set Up the Key Vault</a></p>
</li>
<li><p><a href="#heading-create-the-nodejs-project">Create the Node.js Project</a></p>
</li>
<li><p><a href="#heading-connect-to-key-vault-with-managed-identity">Connect to Key Vault with Managed Identity</a></p>
</li>
<li><p><a href="#heading-cache-secrets-at-startup">Cache Secrets at Startup</a></p>
</li>
<li><p><a href="#heading-use-secrets-in-your-express-api">Use Secrets in Your Express API</a></p>
</li>
<li><p><a href="#heading-test-locally">Test Locally</a></p>
</li>
<li><p><a href="#heading-deploy-to-azure-app-service">Deploy to Azure App Service</a></p>
</li>
<li><p><a href="#heading-grant-key-vault-access-to-the-app">Grant Key Vault Access to the App</a></p>
</li>
<li><p><a href="#heading-rotate-secrets-without-redeploying">Rotate Secrets Without Redeploying</a></p>
</li>
<li><p><a href="#heading-troubleshooting">Troubleshooting</a></p>
</li>
<li><p><a href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
</ol>
<h2 id="heading-how-the-architecture-works">How the Architecture Works</h2>
<p>Before writing any code, it helps to see the full picture:</p>
<pre><code class="language-plaintext"> LOCAL DEVELOPMENT
.-------------------------------------------------------.
|                                                        |
|   [Node.js App]                                        |
|        |                                               |
|        v                                               |
|   [DefaultAzureCredential] ---&gt; az login session       |
|        |                                               |
|        v                                               |
|   [Azure Key Vault]  ---&gt; Returns secrets              |
|        |                                               |
|        v                                               |
|   [In-memory cache]  ---&gt; App uses secrets at runtime  |
'-------------------------------------------------------'

 PRODUCTION (Azure)
.-------------------------------------------------------.
|                                                        |
|   [Azure App Service]                                  |
|        |                                               |
|        v                                               |
|   [DefaultAzureCredential] ---&gt; Managed Identity       |
|        |                                               |
|        v                                               |
|   [Azure Key Vault]  ---&gt; Returns secrets              |
|        |                                               |
|        v                                               |
|   [In-memory cache]  ---&gt; App uses secrets at runtime  |
'-------------------------------------------------------'
</code></pre>
<p>Both environments run the exact same code. <code>DefaultAzureCredential</code> figures out where it is — locally it picks up your <code>az login</code> session, on Azure it uses Managed Identity. You don't switch config files and you don't manage credentials. It just works.</p>
<h2 id="heading-what-is-azure-key-vault">What Is Azure Key Vault?</h2>
<p>Azure Key Vault is Microsoft's managed secret store — it handles secrets, keys, and certificates. For this tutorial, we're only using the secrets part: database passwords, API keys, JWT signing keys, anything your app needs to run but has no business being in your Git history.</p>
<p>Compared to <code>.env</code> files, the practical differences are worth understanding before you write any code.</p>
<p>Rotation is the one I notice most on real projects. Update a secret in Key Vault and every app picks it up on the next restart — no hunting down five different environment configs across staging and production.</p>
<p>Access control is the other big one. Each application only gets permission to read the secrets it actually needs. If one service gets compromised, it can't read credentials belonging to other services.</p>
<p>And every read gets logged. When something goes wrong — and eventually something will — you can see exactly which app accessed which secret, and when. That log is what auditors actually want to see.</p>
<p>I've sat in enough security reviews to know that "we use <code>.env</code> files and tell people not to commit them" doesn't satisfy an auditor. SOC 2, HIPAA, GDPR — they all want demonstrable controls. A vault with an access log is demonstrable.</p>
<h2 id="heading-set-up-the-key-vault">Set Up the Key Vault</h2>
<p>Run these commands. The vault name has to be globally unique across all of Azure — not just your own subscription — so pick something specific. Letters, numbers, and hyphens, 3 to 24 characters.</p>
<pre><code class="language-bash"># Create a resource group (skip if you already have one)
az group create \
  --name keyvault-demo-rg \
  --location eastus

# Create the Key Vault (RBAC enabled by default — required for the role assignment later)
az keyvault create \
  --name your-vault-name \
  --resource-group keyvault-demo-rg \
  --location eastus

# Grant yourself permission to manage secrets (required with RBAC — creators are not auto-assigned)
az role assignment create \
  --role "Key Vault Secrets Officer" \
  --assignee-object-id $(az ad signed-in-user show --query id -o tsv) \
  --scope $(az keyvault show \
    --name your-vault-name \
    --resource-group keyvault-demo-rg \
    --query id -o tsv)

# Add your secrets
az keyvault secret set \
  --vault-name your-vault-name \
  --name "DB-HOST" \
  --value "your-db-host.postgres.database.azure.com"

az keyvault secret set \
  --vault-name your-vault-name \
  --name "DB-PASSWORD" \
  --value "your-super-secret-password"

az keyvault secret set \
  --vault-name your-vault-name \
  --name "JWT-SECRET" \
  --value "your-jwt-signing-secret"
</code></pre>
<p>Verify the secrets were stored:</p>
<pre><code class="language-bash">az keyvault secret list --vault-name your-vault-name --query "[].name" -o tsv
</code></pre>
<p>You should see:</p>
<pre><code class="language-plaintext">DB-HOST
DB-PASSWORD
JWT-SECRET
</code></pre>
<h2 id="heading-create-the-nodejs-project">Create the Node.js Project</h2>
<p>Set up the project structure:</p>
<pre><code class="language-bash">mkdir nodejs-azure-keyvault
cd nodejs-azure-keyvault
npm init -y
npm install express pg jsonwebtoken @azure/keyvault-secrets @azure/identity dotenv
</code></pre>
<p>The two Azure packages do all the work:</p>
<ul>
<li><p><code>@azure/keyvault-secrets</code> — connects to your vault and pulls secrets out</p>
</li>
<li><p><code>@azure/identity</code> — handles auth. Locally, it uses your <code>az login</code> session, in production, it switches to Managed Identity automatically</p>
</li>
</ul>
<p>Add a start script to <code>package.json</code>:</p>
<pre><code class="language-bash">npm pkg set scripts.start="node server.js"
</code></pre>
<p>Create the following file structure:</p>
<pre><code class="language-plaintext">nodejs-azure-keyvault/
|-- src/
|   |-- config/
|   |   `-- secrets.js   # Key Vault client and secret loader
|   |-- db/
|   |   `-- index.js     # PostgreSQL pool using secrets
|   `-- routes/
|       `-- users.js     # Example route
|-- app.js               # Express app
`-- server.js            # Entry point -- loads secrets first
</code></pre>
<h2 id="heading-connect-to-key-vault-with-managed-identity">Connect to Key Vault with Managed Identity</h2>
<p>Create the secrets config file:</p>
<pre><code class="language-javascript">// src/config/secrets.js
const { SecretClient } = require('@azure/keyvault-secrets');
const { DefaultAzureCredential } = require('@azure/identity');

const VAULT_URL = `https://${process.env.KEY_VAULT_NAME}.vault.azure.net`;

const credential = new DefaultAzureCredential();
const client = new SecretClient(VAULT_URL, credential);

async function getSecret(name) {
  const secret = await client.getSecret(name);
  return secret.value;
}

module.exports = { getSecret };
</code></pre>
<p><code>DefaultAzureCredential</code> is the most important part of this setup. It tries a chain of authentication methods in order:</p>
<ol>
<li><p>Environment variables (for CI/CD pipelines)</p>
</li>
<li><p>Azure CLI credentials (for local development — <code>az login</code>)</p>
</li>
<li><p>Managed Identity (for deployed apps on Azure)</p>
</li>
</ol>
<p>This means the exact same code works locally and in production with zero changes. Locally, it uses your <code>az login</code> session. In production, it uses the app's Managed Identity. You never touch credentials.</p>
<h2 id="heading-cache-secrets-at-startup">Cache Secrets at Startup</h2>
<p>Calling Key Vault on every request adds latency and costs money. Load all secrets once at startup and cache them in memory. Replace <code>src/config/secrets.js</code> with this complete version:</p>
<pre><code class="language-javascript">// src/config/secrets.js
const { SecretClient } = require('@azure/keyvault-secrets');
const { DefaultAzureCredential } = require('@azure/identity');

const VAULT_URL = `https://${process.env.KEY_VAULT_NAME}.vault.azure.net`;

const credential = new DefaultAzureCredential();
const client = new SecretClient(VAULT_URL, credential);

// In-memory cache
const cache = {};

async function getSecret(name) {
  if (cache[name]) return cache[name];
  const secret = await client.getSecret(name);
  cache[name] = secret.value;
  return secret.value;
}

async function loadAllSecrets() {
  console.log('Loading secrets from Azure Key Vault...');
  const secretNames = ['DB-HOST', 'DB-PASSWORD', 'JWT-SECRET'];

  await Promise.all(
    secretNames.map(async (name) =&gt; {
      cache[name] = await getSecret(name);
      console.log(`  ✓ ${name} loaded`);
    })
  );

  console.log('All secrets loaded successfully.');
}

function getFromCache(name) {
  if (!cache[name]) throw new Error(`Secret "${name}" not loaded. Did loadAllSecrets() run?`);
  return cache[name];
}

module.exports = { loadAllSecrets, getFromCache };
</code></pre>
<p>The <code>loadAllSecrets</code> function runs once when the application starts. After that, all secrets are served from the in-memory cache with zero latency and zero Key Vault calls.</p>
<h2 id="heading-use-secrets-in-your-express-api">Use Secrets in Your Express API</h2>
<p>Set up the database connection using the cached secrets:</p>
<pre><code class="language-javascript">// src/db/index.js
const { Pool } = require('pg');
const { getFromCache } = require('../config/secrets');

let pool;

function getPool() {
  if (!pool) {
    pool = new Pool({
      host:     getFromCache('DB-HOST'),
      database: process.env.DB_NAME || 'myapp',
      user:     process.env.DB_USER || 'dbadmin',
      password: getFromCache('DB-PASSWORD'),
      port:     parseInt(process.env.DB_PORT || '5432'),
      ssl:      process.env.NODE_ENV === 'production'
                  ? { rejectUnauthorized: false }
                  : false,
    });

    pool.on('error', (err) =&gt; {
      console.error('Unexpected database pool error:', err.message);
    });
  }

  return pool;
}

module.exports = { getPool };
</code></pre>
<p>Notice the distinction: <code>DB-HOST</code> and <code>DB-PASSWORD</code> come from Key Vault because they're sensitive. The database name, username, and port are not — they don't need to be protected, so they use environment variables with sensible defaults. Key Vault is for credentials, not all configuration.</p>
<p>The SSL flag is environment-aware: forced on in production, off locally so Docker connections work without a certificate. The <code>rejectUnauthorized: false</code> setting accepts Azure Database for PostgreSQL's certificate without verifying the CA chain — this is standard for Azure-managed databases. For stricter environments, you can download the Azure root CA and pass it via the <code>ca</code> option in the pool config instead.</p>
<p>Create a sample route that uses JWT verification with the secret from Key Vault:</p>
<pre><code class="language-javascript">// src/routes/users.js
const express = require('express');
const jwt     = require('jsonwebtoken');
const { getFromCache } = require('../config/secrets');
const { getPool }      = require('../db');

const router = express.Router();

// Auth middleware — JWT secret comes from Key Vault, not process.env
function authMiddleware(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing or malformed Authorization header' });
  }

  const token = authHeader.split(' ')[1];

  try {
    req.user = jwt.verify(token, getFromCache('JWT-SECRET'));
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}

// GET /api/users — list users (authenticated)
router.get('/', authMiddleware, async (req, res) =&gt; {
  try {
    const result = await getPool().query(
      'SELECT id, email, created_at FROM users ORDER BY created_at DESC LIMIT 20'
    );
    res.json(result.rows);
  } catch (err) {
    console.error('Database error:', err.message);
    res.status(500).json({ error: 'Internal server error' });
  }
});

// GET /api/users/:id — single user (authenticated)
router.get('/:id', authMiddleware, async (req, res) =&gt; {
  try {
    const result = await getPool().query(
      'SELECT id, email, created_at FROM users WHERE id = $1',
      [req.params.id]
    );
    if (!result.rows[0]) return res.status(404).json({ error: 'User not found' });
    res.json(result.rows[0]);
  } catch (err) {
    console.error('Database error:', err.message);
    res.status(500).json({ error: 'Internal server error' });
  }
});

module.exports = router;
</code></pre>
<p>Notice the error handler returns <code>'Internal server error'</code> instead of <code>err.message</code>. Database errors are surprisingly chatty — they'll hand an attacker your table names, column names, and query structure if you let them through.</p>
<p>Set up the Express application. Both files define <code>authMiddleware</code> locally — yes, it's duplicated. In production, I'd pull this into a shared middleware file. For this tutorial, keeping it local means you can read either file without bouncing between three others:</p>
<pre><code class="language-javascript">// app.js
const express = require('express');
const jwt = require('jsonwebtoken');
const { getFromCache } = require('./src/config/secrets');
const usersRouter = require('./src/routes/users');

const app = express();
app.use(express.json());

// Auth middleware — JWT secret comes from Key Vault, not process.env
function authMiddleware(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing or malformed Authorization header' });
  }
  const token = authHeader.split(' ')[1];
  try {
    req.user = jwt.verify(token, getFromCache('JWT-SECRET'));
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}

// Health check — no auth required
app.get('/health', (req, res) =&gt; {
  res.json({ status: 'healthy', timestamp: new Date().toISOString() });
});

// Status endpoint — proves Key Vault integration without needing a database
app.get('/api/status', authMiddleware, (req, res) =&gt; {
  res.json({
    message: 'All secrets loaded from Azure Key Vault',
    vault: process.env.KEY_VAULT_NAME,
    secrets_loaded: ['DB-HOST', 'DB-PASSWORD', 'JWT-SECRET'],
    authenticated_as: req.user.email,
    timestamp: new Date().toISOString()
  });
});

app.use('/api/users', usersRouter);

app.use((req, res) =&gt; res.status(404).json({ error: 'Route not found' }));
app.use((err, req, res, next) =&gt; {
  console.error('Unhandled error:', err.message);
  res.status(500).json({ error: 'Internal server error' });
});

module.exports = app;
</code></pre>
<p>The entry point loads secrets before starting the server. The server doesn't start unless all secrets load successfully:</p>
<pre><code class="language-javascript">// server.js
require('dotenv').config();
const app = require('./app');
const { loadAllSecrets } = require('./src/config/secrets');

const PORT = process.env.PORT || 3000;

async function start() {
  try {
    await loadAllSecrets();
    app.listen(PORT, () =&gt; {
      console.log(`Server running on port ${PORT}`);
    });
  } catch (err) {
    console.error('Failed to start server:', err.message);
    console.error('Hint: Run "az login" for local development, or check Managed Identity for Azure deployments.');
    process.exit(1);
  }
}

start();
</code></pre>
<p>That <code>process.exit(1)</code> is deliberate. I'd rather the app crash loudly at startup than limp along with missing credentials and fail on the first real request two hours later.</p>
<h2 id="heading-test-locally">Test Locally</h2>
<p>Create a <code>.env</code> file for local development. This only contains the Key Vault name, nothing sensitive:</p>
<pre><code class="language-bash"># .env
KEY_VAULT_NAME=your-vault-name
PORT=3000
</code></pre>
<p>Add <code>.env</code> and the deployment zip to <code>.gitignore</code>:</p>
<pre><code class="language-bash">echo ".env" &gt;&gt; .gitignore
echo "app.zip" &gt;&gt; .gitignore
</code></pre>
<p>Make sure you're logged into Azure CLI:</p>
<pre><code class="language-bash">az login
</code></pre>
<p>Start the application:</p>
<pre><code class="language-bash">npm start
</code></pre>
<p>You should see:</p>
<pre><code class="language-plaintext">Loading secrets from Azure Key Vault...
  ✓ JWT-SECRET loaded
  ✓ DB-PASSWORD loaded
  ✓ DB-HOST loaded
All secrets loaded successfully.
Server running on port 3000
</code></pre>
<p>The order secrets load may vary — <code>Promise.all</code> fetches them in parallel and resolves as each one completes. What matters is that all three are confirmed before the server starts.</p>
<p>Test the health endpoint:</p>
<pre><code class="language-bash">curl http://localhost:3000/health
# {"status":"healthy","timestamp":"2026-07-14T19:38:11.659Z"}
</code></pre>
<p>Now prove the integration end-to-end. Grab the value you stored as <code>JWT-SECRET</code> and use it to sign a test token — paste it in for <code>YOUR-JWT-SECRET-VALUE</code>. Then hit <code>/api/status</code> with it:</p>
<pre><code class="language-bash">node -e "const jwt = require('jsonwebtoken'); console.log(jwt.sign({id:1, email:'test@test.com'}, 'YOUR-JWT-SECRET-VALUE', {expiresIn:'1h'}));"
</code></pre>
<p>On Linux/macOS:</p>
<pre><code class="language-bash">curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:3000/api/status
</code></pre>
<p>On Windows PowerShell:</p>
<pre><code class="language-powershell">Invoke-RestMethod -Uri "http://localhost:3000/api/status" -Headers @{Authorization = "Bearer YOUR_TOKEN"}
</code></pre>
<p>You should see:</p>
<pre><code class="language-json">{
  "message": "All secrets loaded from Azure Key Vault",
  "vault": "your-vault-name",
  "secrets_loaded": ["DB-HOST", "DB-PASSWORD", "JWT-SECRET"],
  "authenticated_as": "test@test.com",
  "timestamp": "2026-07-14T19:50:08.687Z"
}
</code></pre>
<p>If you got that response, the whole chain worked. The JWT was signed and verified using a secret that lived only in Key Vault — not in your code, not in your<code>.env</code>, not anywhere in the repo. Your <code>az login</code> session handled the auth locally. In production, Managed Identity takes over. Same code, nothing changes.</p>
<h3 id="heading-test-the-full-database-flow-with-docker">Test the Full Database Flow with Docker</h3>
<p>The app reads <code>DB-HOST</code> and <code>DB-PASSWORD</code> from Key Vault, so those secrets need to match your local Docker container. Update them now:</p>
<pre><code class="language-bash">az keyvault secret set --vault-name your-vault-name --name "DB-HOST" --value "localhost"
az keyvault secret set --vault-name your-vault-name --name "DB-PASSWORD" --value "demopassword123"
</code></pre>
<p>Docker up a Postgres container. The password has to match <code>demopassword123</code> — that's what you just put in Key Vault:</p>
<pre><code class="language-bash">docker run --name pg-demo \
  -e POSTGRES_USER=dbadmin \
  -e POSTGRES_PASSWORD=demopassword123 \
  -e POSTGRES_DB=myapp \
  -p 5432:5432 \
  -d postgres:15
</code></pre>
<p>Get the table created and throw in some test rows:</p>
<pre><code class="language-bash">docker exec -it pg-demo psql -U dbadmin -d myapp -c \
  "CREATE TABLE IF NOT EXISTS users (id SERIAL PRIMARY KEY, email VARCHAR(255) UNIQUE NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW());"

docker exec -it pg-demo psql -U dbadmin -d myapp -c \
  "INSERT INTO users (email) VALUES ('alice@example.com'), ('bob@example.com'), ('carol@example.com');"
</code></pre>
<p>Kill the server and bring it back up — secrets load at startup, so it needs a fresh run to pick up what you just changed in Key Vault:</p>
<pre><code class="language-bash">npm start
</code></pre>
<p>Call the users endpoint with a valid JWT:</p>
<pre><code class="language-bash"># Generate a token (use the same value you stored as JWT-SECRET in Key Vault)
node -e "const jwt = require('jsonwebtoken'); console.log(jwt.sign({id:1, email:'test@test.com'}, 'YOUR-JWT-SECRET-VALUE', {expiresIn:'1h'}));"
</code></pre>
<p>On Linux/macOS:</p>
<pre><code class="language-bash">curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:3000/api/users
</code></pre>
<p>On Windows PowerShell:</p>
<pre><code class="language-powershell">Invoke-RestMethod -Uri "http://localhost:3000/api/users" -Headers @{Authorization = "Bearer YOUR_TOKEN"}
</code></pre>
<p>You should see:</p>
<pre><code class="language-json">[
  { "id": 1, "email": "alice@example.com", "created_at": "2026-07-14T19:59:21.064Z" },
  { "id": 2, "email": "bob@example.com",   "created_at": "2026-07-14T19:59:21.064Z" },
  { "id": 3, "email": "carol@example.com", "created_at": "2026-07-14T19:59:21.064Z" }
]
</code></pre>
<p>That query ran using a password that came straight from Key Vault. It's not in your <code>.env</code>, not hardcoded anywhere, and not in a local variable. The repo has nothing worth stealing.</p>
<p>Before you deploy, put the real production values back in Key Vault:</p>
<pre><code class="language-bash">az keyvault secret set --vault-name your-vault-name --name "DB-HOST" --value "your-db-host.postgres.database.azure.com"
az keyvault secret set --vault-name your-vault-name --name "DB-PASSWORD" --value "your-super-secret-password"
</code></pre>
<p>If you skip this, the deployed app will try to connect to <code>localhost</code> and fail immediately — <code>localhost</code> doesn't exist on App Service.</p>
<h2 id="heading-deploy-to-azure-app-service">Deploy to Azure App Service</h2>
<p><strong>Note:</strong> This section creates the App Service infrastructure. The actual code deployment (zip upload) happens at the end of the next section — the app must have Key Vault access configured before its first startup, or it will fail immediately and exit.</p>
<p>Create the App Service:</p>
<pre><code class="language-bash"># Create an App Service Plan (B1 is the cheapest paid tier)
az appservice plan create \
  --name keyvault-demo-plan \
  --resource-group keyvault-demo-rg \
  --sku B1 \
  --is-linux

# Create the Web App
az webapp create \
  --name my-keyvault-node-app \
  --resource-group keyvault-demo-rg \
  --plan keyvault-demo-plan \
  --runtime "NODE:18-lts"

# Set app settings — KEY_VAULT_NAME tells the app which vault to use
# NODE_ENV=production enables SSL for the database connection
az webapp config appsettings set \
  --name my-keyvault-node-app \
  --resource-group keyvault-demo-rg \
  --settings KEY_VAULT_NAME=your-vault-name NODE_ENV=production
</code></pre>
<h2 id="heading-grant-key-vault-access-to-the-app">Grant Key Vault Access to the App</h2>
<p>Enable Managed Identity on the app. This gives it an identity in Microsoft Entra ID that Key Vault can trust:</p>
<pre><code class="language-bash"># Enable system-assigned managed identity
az webapp identity assign \
  --name my-keyvault-node-app \
  --resource-group keyvault-demo-rg
</code></pre>
<p>The following commands capture the <code>principalId</code> automatically and use it to grant the role:</p>
<pre><code class="language-bash"># Get the principal ID
PRINCIPAL_ID=$(az webapp identity show \
  --name my-keyvault-node-app \
  --resource-group keyvault-demo-rg \
  --query principalId \
  --output tsv)

# Get the Key Vault resource ID
KV_ID=$(az keyvault show \
  --name your-vault-name \
  --resource-group keyvault-demo-rg \
  --query id \
  --output tsv)

# Grant the app the "Key Vault Secrets User" role
az role assignment create \
  --role "Key Vault Secrets User" \
  --assignee-object-id $PRINCIPAL_ID \
  --scope $KV_ID
</code></pre>
<p>The <code>Key Vault Secrets User</code> role allows the app to read secrets. It can't create, update, or delete them. This is the principle of least privilege — the application can only do what it needs to do.</p>
<p>Time to ship it. Linux/macOS can run this directly — Windows users, open Git Bash (it ships with Git for Windows):</p>
<pre><code class="language-bash">zip -r app.zip . -x "node_modules/*" ".git/*" ".env" "app.zip"
</code></pre>
<p>Then deploy:</p>
<pre><code class="language-bash">az webapp deployment source config-zip \
  --name my-keyvault-node-app \
  --resource-group keyvault-demo-rg \
  --src app.zip
</code></pre>
<p>The deployed application authenticates to Key Vault using its Managed Identity automatically. No passwords, no client secrets, no credentials of any kind in the deployment.</p>
<p>Check the health endpoint to confirm it's running:</p>
<pre><code class="language-bash">curl https://my-keyvault-node-app.azurewebsites.net/health
# {"status":"healthy","timestamp":"..."}
</code></pre>
<p>If it won't start, pull the logs:</p>
<pre><code class="language-bash">az webapp log tail --name my-keyvault-node-app --resource-group keyvault-demo-rg
</code></pre>
<p>Nine times out of ten, it's that the Key Vault role assignment has not been propagated yet. Give it 2–3 minutes, then restart:</p>
<pre><code class="language-bash">az webapp restart --name my-keyvault-node-app --resource-group keyvault-demo-rg
</code></pre>
<h2 id="heading-rotate-secrets-without-redeploying">Rotate Secrets Without Redeploying</h2>
<p>One of the biggest practical benefits of Key Vault is secret rotation. When a database password needs to change, you update it in Key Vault — not in your app:</p>
<pre><code class="language-bash">az keyvault secret set \
  --vault-name your-vault-name \
  --name "DB-PASSWORD" \
  --value "new-rotated-password"
</code></pre>
<p>The cache builds at startup, so you don't need a redeploy — a restart is enough:</p>
<pre><code class="language-bash">az webapp restart \
  --name my-keyvault-node-app \
  --resource-group keyvault-demo-rg
</code></pre>
<p>No code change. No new deployment. The secret is rotated, and the app is using the new value in seconds.</p>
<p>If you need zero-downtime rotation, add a <code>/refresh-secrets</code> endpoint behind admin auth that clears the cache and then calls <code>loadAllSecrets()</code>. The order matters — <code>loadAllSecrets()</code> uses <code>getSecret()</code> which returns cached values if they exist, so you must clear the cache first, or it will reload nothing. This is optional but useful for long-running processes that can't afford a restart.</p>
<h2 id="heading-troubleshooting">Troubleshooting</h2>
<p><code>CredentialUnavailableError: DefaultAzureCredential failed to retrieve a token</code></p>
<p>You're not logged into Azure CLI. Run <code>az login</code> and try again. On Azure App Service, check that Managed Identity is enabled and the role assignment was created correctly.</p>
<p><code>RestError: Forbidden — The user does not have secrets get permission</code></p>
<p>The Managed Identity isn't wired up to Key Vault yet. Go back and run the <code>az role assignment create</code> command. If you already did, it might just need time. Azure can take 2–3 minutes to propagate role assignments, so give it a moment before you dig further.</p>
<p><code>Error: Secret "DB-PASSWORD" not loaded. Did loadAllSecrets() run?</code></p>
<p><code>getFromCache()</code> ran before <code>loadAllSecrets()</code> finished, meaning the startup sequence is out of order. Open <code>server.js</code> and confirm <code>await loadAllSecrets()</code> comes before <code>app.listen()</code>. If the order's fine, the secret might just not be in the vault yet. Run <code>az keyvault secret list --vault-name YOUR_VAULT</code> to double-check. (A name mismatch — wrong case, typo — throws <code>SecretNotFound</code> instead, which is the entry below.)</p>
<p><strong>App starts locally but fails on Azure App Service</strong></p>
<p>Almost always, the app setting. Either <code>KEY_VAULT_NAME</code> isn't in App Service configuration at all, or the vault name has a typo. Run <code>az webapp log tail</code> to see the actual startup error — that'll tell you which one.</p>
<p><code>AuthorizationFailed</code> <strong>when running</strong> <code>az role assignment create</code></p>
<p>You are a guest user in your Azure tenant and lack the Owner role needed to assign roles. Switch the existing vault to the access policy model — no need to recreate it or lose your secrets:</p>
<pre><code class="language-bash">az keyvault update \
  --name your-vault-name \
  --resource-group keyvault-demo-rg \
  --enable-rbac-authorization false
</code></pre>
<p>If this happened during <strong>Set Up the Key Vault</strong> (granting yourself access), run:</p>
<pre><code class="language-bash">az keyvault set-policy \
  --name your-vault-name \
  --object-id $(az ad signed-in-user show --query id -o tsv) \
  --secret-permissions get set list delete
</code></pre>
<p>If this happened during <strong>Grant Key Vault Access to the App</strong> (granting the Managed Identity access), run:</p>
<pre><code class="language-bash">az keyvault set-policy \
  --name your-vault-name \
  --object-id $PRINCIPAL_ID \
  --secret-permissions get list
</code></pre>
<p><strong>Key Vault returns</strong> <code>SecretNotFound</code></p>
<p>The secret was never added, was deleted, or its name doesn't match exactly what your code requests — Key Vault secret names are case-sensitive. A secret named <code>db-password</code> and a request for <code>DB-PASSWORD</code> are different names. Run <code>az keyvault secret list --vault-name YOUR_VAULT</code> and compare what's actually in the vault against what <code>loadAllSecrets()</code> is asking for in <code>src/config/secrets.js</code>. Usually, it's a casing issue or a stray hyphen.</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>The <code>.env</code> file in this project contains exactly one value: the Key Vault name. That's not sensitive. Every actual secret — database passwords, API keys, signing secrets — lives in Key Vault and never touches your codebase or your deployment pipeline.</p>
<p>This is the pattern I use on Azure projects now. The startup check is the part I find most useful in practice: if Key Vault is unreachable or a secret is missing, the server exits immediately with a clear error instead of starting up broken and failing on the first real request. You find out right away, rather than getting an obscure database connection error two hours later.</p>
<p>To add another secret, put it in Key Vault and drop its name into the <code>secretNames</code> array — that's it. Everything else scales with it.</p>
<p>The full working code is on GitHub: <a href="https://github.com/ziaongit/nodejs-azure-keyvault">nodejs-azure-keyvault</a></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Fix Common Web Application Security Vulnerabilities in Node.js ]]>
                </title>
                <description>
                    <![CDATA[ Here's something that tends to surprise developers who are new to security: most web vulnerabilities aren't the result of sophisticated attacks. They come from code patterns that look completely reaso ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-fix-common-web-application-security-vulnerabilities-in-node-js/</link>
                <guid isPermaLink="false">6a581c65dba34eeb664be8e2</guid>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ owasp ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Hackita ]]>
                </dc:creator>
                <pubDate>Wed, 15 Jul 2026 23:48:53 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/b2ec81b6-d6eb-41f0-9fa5-7570914ef97d.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Here's something that tends to surprise developers who are new to security: most web vulnerabilities aren't the result of sophisticated attacks. They come from code patterns that look completely reasonable: trusting a value from the URL, applying a request body to a database update, or running two queries where one should've been enough.</p>
<p>This guide covers six of those patterns. For each one, you'll see a real code example that creates the vulnerability, an explanation of what makes it dangerous, and a corrected version with notes on exactly what changed and why.</p>
<p>No security background is required to follow along here. It'll just help to have some familiarity with Node.js and SQL.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>The examples assume you're comfortable with:</p>
<ul>
<li><p>Node.js and Express.js basics</p>
</li>
<li><p>SQL queries</p>
</li>
<li><p>How HTTP requests and responses work</p>
</li>
<li><p>Basic authentication concepts (sessions, tokens)</p>
</li>
</ul>
<p><strong>Note on code examples</strong>: Throughout this tutorial, <code>db.query()</code> is a fictional database helper that returns a single row object for <code>SELECT</code> queries (or <code>null</code> if not found), and a result object for <code>INSERT</code>/<code>UPDATE</code> queries. The <code>connection.query()</code> in the race conditions section uses the <a href="https://github.com/sidorares/node-mysql2">mysql2</a> promise API directly, where <code>query()</code> returns <code>[rows, fields]</code>. Adapt the syntax to the database driver you use.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-1-broken-access-control-and-idor">1. Broken Access Control and IDOR</a></p>
</li>
<li><p><a href="#heading-2-mass-assignment">2. Mass Assignment</a></p>
</li>
<li><p><a href="#heading-3-prototype-pollution">3. Prototype Pollution</a></p>
</li>
<li><p><a href="#heading-4-race-conditions">4. Race Conditions</a></p>
</li>
<li><p><a href="#heading-5-business-logic-flaws">5. Business Logic Flaws</a></p>
</li>
<li><p><a href="#heading-6-jwt-misconfiguration">6. JWT Misconfiguration</a></p>
</li>
<li><p><a href="#heading-summary">Summary</a></p>
</li>
</ul>
<h2 id="heading-1-broken-access-control-and-idor">1. Broken Access Control and IDOR</h2>
<p><a href="https://owasp.org/Top10/A01_2021-Broken_Access_Control/">Broken Access Control</a> has topped the OWASP Top 10 since 2021, and it's not hard to see why. The most common form is <strong>Insecure Direct Object Reference (IDOR)</strong>: the application exposes a database ID in a URL, a user changes the number, and suddenly they're looking at someone else's data.</p>
<p>The fix seems obvious in hindsight. But it keeps appearing in production code because authentication and authorization get conflated. Confirming that a user is logged in is not the same as confirming they're allowed to access a specific resource.</p>
<h3 id="heading-how-to-identify-this-vulnerability-in-your-code">How to Identify This Vulnerability in Your Code</h3>
<p>Here's a typical user profile endpoint:</p>
<pre><code class="language-javascript">// Express.js - Vulnerable
app.get('/api/users/:id/profile', authenticate, async (req, res) =&gt; {
  const userId = req.params.id;

  const user = await db.query(
    'SELECT id, name, email, address FROM users WHERE id = ?',
    [userId]
  );

  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }

  res.json(user);
});
</code></pre>
<p>The <code>authenticate</code> middleware confirms that the request includes a valid token. But it doesn't confirm whether the authenticated user is allowed to access the requested resource.</p>
<p>Any authenticated user can request <code>/api/users/1/profile</code>, <code>/api/users/2/profile</code>, and so on and retrieve other users' data.</p>
<h3 id="heading-why-this-matters">Why This Matters</h3>
<p>Authentication confirms <em>who</em> you are. Authorization confirms <em>what you're allowed to do</em>. The code above does the first and skips the second entirely.</p>
<p>With a sequential numeric ID, a curious user doesn't need any special tools. They can just change <code>1</code> to <code>2</code> in the URL. But the same problem exists with UUIDs or slugs if the ownership check is missing.</p>
<h3 id="heading-how-to-fix-this-vulnerability">How to Fix This Vulnerability</h3>
<p>Verify server-side that the authenticated user owns — or is explicitly authorized to access — the requested resource:</p>
<pre><code class="language-javascript">// Express.js - Secure
app.get('/api/users/:id/profile', authenticate, async (req, res) =&gt; {
  // Reject anything that isn't a string of digits — parseInt("12abc") would return 12
  if (!/^\d+$/.test(req.params.id)) {
    return res.status(400).json({ error: 'Invalid user ID' });
  }

  const requestedId = Number(req.params.id);

  if (requestedId &lt; 1) {
    return res.status(400).json({ error: 'Invalid user ID' });
  }

  // The authenticated user's ID is set by the authenticate middleware
  const authenticatedId = req.user.id;

  // Enforce ownership: users can only access their own profile
  if (requestedId !== authenticatedId) {
    return res.status(403).json({ error: 'Forbidden' });
  }

  const user = await db.query(
    'SELECT id, name, email, address FROM users WHERE id = ?',
    [requestedId]
  );

  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }

  res.json(user);
});
</code></pre>
<p>For admin endpoints that legitimately need to access any user, enforce role-based authorization explicitly:</p>
<pre><code class="language-javascript">// Admin endpoint with explicit role check
app.get('/api/admin/users/:id', authenticate, requireRole('admin'), async (req, res) =&gt; {
  if (!/^\d+$/.test(req.params.id)) {
    return res.status(400).json({ error: 'Invalid user ID' });
  }

  const userId = Number(req.params.id);

  const user = await db.query(
    'SELECT id, name, email, role FROM users WHERE id = ?',
    [userId]
  );

  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }

  res.json(user);
});
</code></pre>
<p>Here's what changed and why:</p>
<ul>
<li><p><code>/^\d+$/.test(req.params.id)</code> rejects anything that isn't a pure string of digits. <code>parseInt("12abc", 10)</code> would silently return <code>12</code> and pass further checks. The regex prevents this.</p>
</li>
<li><p><code>Number(req.params.id)</code> converts the already-validated string to a number safely.</p>
</li>
<li><p>The comparison <code>requestedId !== authenticatedId</code> enforces ownership.</p>
</li>
<li><p>Admin functionality is a separate endpoint with its own authorization check.</p>
</li>
</ul>
<p><strong>Never infer authorization from a URL parameter. Derive it from the authenticated session.</strong></p>
<h2 id="heading-2-mass-assignment">2. Mass Assignment</h2>
<p>Mass assignment is one of those vulnerabilities that's almost invisible when you write it. You're just being efficient, right? Why iterate over fields manually when you can pass the whole object?</p>
<p>The problem is that your database table knows about fields your users were never supposed to touch.</p>
<h3 id="heading-how-to-identify-this-vulnerability-in-your-code">How to Identify This Vulnerability in Your Code</h3>
<p>Here's a user profile update endpoint:</p>
<pre><code class="language-javascript">// Express.js - Vulnerable
app.put('/api/users/me', authenticate, async (req, res) =&gt; {
  const userId = req.user.id;

  // req.body contains everything the client sends
  const updates = req.body;

  await db.query(
    'UPDATE users SET ? WHERE id = ?',
    [updates, userId]
  );

  res.json({ success: true });
});
</code></pre>
<p>The <code>users</code> table has these columns:</p>
<pre><code class="language-sql">CREATE TABLE users (
  id           INT PRIMARY KEY,
  name         VARCHAR(100),
  email        VARCHAR(100),
  bio          TEXT,
  role         ENUM('user', 'moderator', 'admin') DEFAULT 'user',
  credits      INT DEFAULT 0,
  is_banned    BOOLEAN DEFAULT false
);
</code></pre>
<p>The developer intended users to update <code>name</code>, <code>email</code>, and <code>bio</code>. But <code>role</code>, <code>credits</code>, and <code>is_banned</code> are also in the table — and the query updates whatever fields the client sends.</p>
<h3 id="heading-why-this-matters">Why This Matters</h3>
<p>That request body goes straight into the SQL query. The <code>users</code> table also has <code>role</code>, <code>credits</code>, and <code>is_banned</code> — and the query doesn't know or care which fields the developer "intended" to expose.</p>
<p>A user who sends this:</p>
<pre><code class="language-json">{
  "name": "Alice",
  "role": "admin",
  "credits": 100000,
  "is_banned": false
}
</code></pre>
<p>Has just promoted themselves to admin, cleared their ban, and given themselves a hundred thousand credits.</p>
<h3 id="heading-how-to-fix-this-vulnerability">How to Fix This Vulnerability</h3>
<p>Build the update object yourself, field by field, using an explicit allowlist:</p>
<pre><code class="language-javascript">// Express.js - Secure
app.put('/api/users/me', authenticate, async (req, res) =&gt; {
  const userId = req.user.id;

  // Only these fields may be updated by the user
  const ALLOWED_FIELDS = ['name', 'email', 'bio'];
  const updates = {};

  for (const field of ALLOWED_FIELDS) {
    if (req.body[field] !== undefined) {
      updates[field] = req.body[field];
    }
  }

  if (Object.keys(updates).length === 0) {
    return res.status(400).json({ error: 'No valid fields provided' });
  }

  // Validate individual fields
  // isValidEmail is a simple helper: /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
  if (updates.email &amp;&amp; !isValidEmail(updates.email)) {
    return res.status(400).json({ error: 'Invalid email format' });
  }

  if (updates.name &amp;&amp; (typeof updates.name !== 'string' || updates.name.length &gt; 100)) {
    return res.status(400).json({ error: 'Name must be a string of 100 characters or fewer' });
  }

  await db.query(
    'UPDATE users SET ? WHERE id = ?',
    [updates, userId]
  );

  res.json({ success: true });
});
</code></pre>
<p>For admin operations that legitimately update sensitive fields, use a separate endpoint with its own authorization:</p>
<pre><code class="language-javascript">// Admin-only endpoint for changing user roles
app.put('/api/admin/users/:id/role', authenticate, requireRole('admin'), async (req, res) =&gt; {
  if (!/^\d+$/.test(req.params.id)) {
    return res.status(400).json({ error: 'Invalid user ID' });
  }

  const userId = Number(req.params.id);
  const { role } = req.body;

  const VALID_ROLES = ['user', 'moderator', 'admin'];

  if (!VALID_ROLES.includes(role)) {
    return res.status(400).json({ error: 'Invalid role' });
  }

  await db.query(
    'UPDATE users SET role = ? WHERE id = ?',
    [role, userId]
  );

  res.json({ success: true });
});
</code></pre>
<p><strong>Don't spread</strong> <code>req.body</code> <strong>into a database query. Build the update object field by field.</strong></p>
<h2 id="heading-3-prototype-pollution">3. Prototype Pollution</h2>
<p>Prototype pollution occurs when untrusted data is merged into an object recursively, allowing an attacker to inject properties into <code>Object.prototype</code> (the base object that every plain JavaScript object inherits from).</p>
<p>The OWASP Top 10 covers this under <a href="https://owasp.org/Top10/A08_2021-Software_and_Data_Integrity_Failures/">Software and Data Integrity Failures (A08:2021)</a>.</p>
<h3 id="heading-how-to-identify-this-vulnerability-in-your-code">How to Identify This Vulnerability in Your Code</h3>
<p>A configuration merge utility that processes user-supplied settings:</p>
<pre><code class="language-javascript">// Vulnerable recursive merge function
function mergeConfig(target, source) {
  for (const key of Object.keys(source)) {
    if (typeof source[key] === 'object' &amp;&amp; source[key] !== null) {
      if (!target[key]) target[key] = {};
      mergeConfig(target[key], source[key]); // recursive call
    } else {
      target[key] = source[key]; // triggers __proto__ setter via bracket notation
    }
  }
}

app.post('/api/settings', authenticate, (req, res) =&gt; {
  const userSettings = {};
  mergeConfig(userSettings, req.body); // merge untrusted input
  applySettings(userSettings);
  res.json({ success: true });
});
</code></pre>
<h3 id="heading-why-this-matters">Why This Matters</h3>
<p>An attacker sends this request body:</p>
<pre><code class="language-json">{
  "__proto__": {
    "isAdmin": true
  }
}
</code></pre>
<p>The recursive <code>mergeConfig</code> function reaches the <code>__proto__</code> key and executes <code>target['__proto__']['isAdmin'] = true</code>. Because <code>__proto__</code> is JavaScript's prototype accessor, this writes directly to <code>Object.prototype</code>. After the merge:</p>
<pre><code class="language-javascript">const anyObject = {};
console.log(anyObject.isAdmin); // true — inherited from Object.prototype
</code></pre>
<p>Every plain object in the running application now inherits <code>isAdmin: true</code>. If any authorization check looks like this:</p>
<pre><code class="language-javascript">if (user.isAdmin) { /* grant admin access */ }
</code></pre>
<p>That check now passes for every user, regardless of their actual role.</p>
<h3 id="heading-how-to-fix-this-vulnerability">How to Fix This Vulnerability</h3>
<p>Store user state server-side and validate each field individually. Never recursively merge untrusted input:</p>
<pre><code class="language-javascript">// Express.js - Secure
app.post('/api/settings', authenticate, async (req, res) =&gt; {
  // Load current settings from the database — never from the client
  const current = await db.query(
    'SELECT theme, language, notifications FROM user_settings WHERE user_id = ?',
    [req.user.id]
  );

  // Validate each field against an explicit allowlist
  const safeSettings = {
    theme: validateEnum(req.body.theme, ['light', 'dark'], current.theme),
    language: validateEnum(req.body.language, ['en', 'es', 'fr', 'de'], current.language),
    notifications: typeof req.body.notifications === 'boolean'
      ? req.body.notifications
      : current.notifications
  };

  await db.query(
    'UPDATE user_settings SET ? WHERE user_id = ?',
    [safeSettings, req.user.id]
  );

  res.json({ success: true });
});

function validateEnum(value, allowed, defaultValue) {
  return allowed.includes(value) ? value : defaultValue;
}
</code></pre>
<p>If you need a merge utility, use <code>Object.create(null)</code> as the base — it has no prototype, so <code>__proto__</code> can't be polluted — and allowlist keys explicitly:</p>
<pre><code class="language-javascript">// Safe merge: base object with no prototype
function safeMerge(allowedKeys, source) {
  const result = Object.create(null); // no prototype = no pollution possible

  for (const key of allowedKeys) {
    if (key in source &amp;&amp; typeof source[key] !== 'object') {
      result[key] = source[key];
    }
  }

  return result;
}
</code></pre>
<p><strong>Rules</strong>:</p>
<ul>
<li><p>Never recursively merge untrusted input into a plain object.</p>
</li>
<li><p>Store application state server-side. Don't trust clients to carry it.</p>
</li>
<li><p>Use <code>Object.create(null)</code> for data containers that will hold untrusted keys.</p>
</li>
<li><p>Validate each field by type and allowed values before using it.</p>
</li>
</ul>
<h2 id="heading-4-race-conditions">4. Race Conditions</h2>
<p>Race conditions are tricky because the code is perfectly correct. It's the timing that breaks it. Two requests arrive at almost the same moment, both check the same condition, both see a valid result, and both proceed. The result is something that should only happen once, happening twice.</p>
<p>This is called a <strong>Time-of-Check to Time-of-Use (TOCTOU)</strong> problem: the state you checked is no longer the state you're acting on.</p>
<h3 id="heading-how-to-identify-this-pattern-in-your-code">How to Identify This Pattern in Your Code</h3>
<p>A single-use coupon redemption endpoint:</p>
<pre><code class="language-javascript">// Express.js - Vulnerable
app.post('/api/redeem-coupon', authenticate, async (req, res) =&gt; {
  const { couponCode } = req.body;
  const userId = req.user.id;

  // Step 1: Check if the coupon is still valid
  const coupon = await db.query(
    'SELECT id, discount_amount, used FROM coupons WHERE code = ? AND used = false',
    [couponCode]
  );

  if (!coupon) {
    return res.status(400).json({ error: 'Invalid or already used coupon' });
  }

  // Time gap: another request can pass Step 1 here before Step 3 runs

  // Step 2: Apply the discount
  await applyDiscountToOrder(userId, coupon.discount_amount);

  // Step 3: Mark coupon as used
  await db.query(
    'UPDATE coupons SET used = true, used_by = ? WHERE code = ?',
    [userId, couponCode]
  );

  res.json({ success: true });
});
</code></pre>
<h3 id="heading-why-this-matters">Why This Matters</h3>
<p>Two requests arrive with the same coupon code at nearly the same time. Both hit Step 1 before either reaches Step 3. Both read <code>used = false</code>. Both apply the discount. One coupon, used twice.</p>
<p>The same window exists anywhere you read-then-write: balance checks before deductions, inventory checks before reservations, vote checks before incrementing. Any of them can be exploited the same way.</p>
<h3 id="heading-how-to-fix-this-vulnerability">How to Fix This Vulnerability</h3>
<p>Replace the check-then-act pattern with an atomic operation. An atomic database update guarantees that the condition check and the write happen as a single indivisible unit:</p>
<pre><code class="language-javascript">// Express.js - Secure
app.post('/api/redeem-coupon', authenticate, async (req, res) =&gt; {
  const { couponCode } = req.body;
  const userId = req.user.id;

  const connection = await db.getConnection();

  try {
    await connection.beginTransaction();

    // Atomic: only one request can update a row where used = false.
    // The database row lock ensures only one request succeeds.
    const [result] = await connection.query(
      `UPDATE coupons
       SET used = true, used_by = ?, used_at = NOW()
       WHERE code = ? AND used = false`,
      [userId, couponCode]
    );

    if (result.affectedRows === 0) {
      await connection.rollback();
      return res.status(400).json({ error: 'Invalid or already used coupon' });
    }

    const [couponRows] = await connection.query(
      'SELECT discount_amount FROM coupons WHERE code = ?',
      [couponCode]
    );
    const coupon = couponRows[0];

    await applyDiscountToOrder(userId, coupon.discount_amount, connection);

    await connection.commit();

    res.json({ success: true, discount: coupon.discount_amount });

  } catch (error) {
    await connection.rollback();
    console.error('Coupon redemption failed:', error);
    res.status(500).json({ error: 'Could not process the coupon' });
  } finally {
    connection.release();
  }
});
</code></pre>
<p>The key change is <code>UPDATE ... WHERE code = ? AND used = false</code>. The database acquires a row lock during the update. Only one concurrent request can succeed. The second request finds <code>affectedRows = 0</code> and returns an error — correctly.</p>
<p><strong>Any time you read a value to make a decision before writing — that's a potential race condition. Make the check and the write atomic.</strong></p>
<h2 id="heading-5-business-logic-flaws">5. Business Logic Flaws</h2>
<p>Business logic flaws are the hardest category to catch. Automated scanners won't find them, and code review might miss them too, because the code works exactly as written. The problem is in what the code was designed to do, not how it does it.</p>
<p>The most common form: trusting the client to send sensible numeric values.</p>
<h3 id="heading-how-to-identify-this-vulnerability-in-your-code">How to Identify This Vulnerability in Your Code</h3>
<p>An e-commerce checkout endpoint:</p>
<pre><code class="language-javascript">// Express.js - Vulnerable
app.post('/api/checkout', authenticate, async (req, res) =&gt; {
  const { items } = req.body;

  let total = 0;
  const processedItems = [];

  for (const item of items) {
    const product = await db.query(
      'SELECT id, price FROM products WHERE id = ?',
      [item.productId]
    );

    if (!product) {
      return res.status(400).json({ error: `Product not found: ${item.productId}` });
    }

    // Trust the quantity value from the client
    const itemTotal = product.price * item.quantity;
    total += itemTotal;

    processedItems.push({ productId: product.id, quantity: item.quantity, price: product.price });
  }

  if (total &gt; 100) {
    total = total * 0.9; // 10% discount
  }

  await createOrder(req.user.id, processedItems, total);
  res.json({ success: true, total });
});
</code></pre>
<h3 id="heading-why-this-matters">Why This Matters</h3>
<p><strong>Problem 1 — Negative quantity</strong>: The code multiplies the server's price by the client's quantity without checking that the quantity is positive. A user sends <code>"quantity": -5</code> on an expensive item. Its contribution to the total becomes negative, reducing the overall total.</p>
<p><strong>Problem 2 — Floating point arithmetic</strong>: <code>0.1 + 0.2</code> in JavaScript is <code>0.30000000000000004</code>. Without rounding, financial calculations accumulate errors over time.</p>
<p><strong>Problem 3 — Discount manipulation</strong>: If a separate endpoint allows modifying an order after checkout without recalculating the total, a user could add items to earn the discount, then remove items while keeping the discounted price.</p>
<h3 id="heading-how-to-fix-this-vulnerability">How to Fix This Vulnerability</h3>
<p>Validate every numeric input and recalculate all totals server-side. Use integer arithmetic (cents) for money to avoid floating point errors:</p>
<pre><code class="language-javascript">// Express.js - Secure
app.post('/api/checkout', authenticate, async (req, res) =&gt; {
  const { items } = req.body;

  if (!Array.isArray(items) || items.length === 0) {
    return res.status(400).json({ error: 'Cart must contain at least one item' });
  }

  if (items.length &gt; 50) {
    return res.status(400).json({ error: 'Cart cannot contain more than 50 items' });
  }

  let totalCents = 0; // Integer arithmetic avoids floating point errors
  const processedItems = [];

  for (const item of items) {
    if (!/^\d+$/.test(String(item.productId))) {
      return res.status(400).json({ error: `Invalid product ID: ${item.productId}` });
    }

    const productId = Number(item.productId);

    // Validate quantity: must be a string of digits only, between 1 and 10
    // parseInt("3abc", 10) returns 3 — we use regex to prevent this
    if (!/^\d+$/.test(String(item.quantity))) {
      return res.status(400).json({
        error: `Invalid quantity for product ${productId}`
      });
    }

    const quantity = Number(item.quantity);

    if (quantity &lt; 1 || quantity &gt; 10) {
      return res.status(400).json({
        error: `Quantity for product ${productId} must be between 1 and 10`
      });
    }

    const product = await db.query(
      'SELECT id, name, price_cents, stock FROM products WHERE id = ? AND active = true',
      [productId]
    );

    if (!product) {
      return res.status(400).json({ error: `Product not found: ${productId}` });
    }

    if (product.stock &lt; quantity) {
      return res.status(400).json({
        error: `Insufficient stock for "${product.name}"`
      });
    }

    // Use the server's price — never trust a price from the client
    totalCents += product.price_cents * quantity;

    processedItems.push({
      productId: product.id,
      name: product.name,
      quantity,
      unitPriceCents: product.price_cents
    });
  }

  // Integer arithmetic for the discount calculation
  const discountMultiplier = totalCents &gt; 10000 ? 90 : 100; // 10000 cents = $100
  const finalTotalCents = Math.round(totalCents * discountMultiplier / 100);

  await createOrder(req.user.id, processedItems, finalTotalCents);

  res.json({
    success: true,
    total: (finalTotalCents / 100).toFixed(2),
    currency: 'USD'
  });
});
</code></pre>
<p>What changed and why:</p>
<ul>
<li><p>Integer (cents) arithmetic eliminates floating point errors. <code>10000 + 3000</code> in integer cents is always exact.</p>
</li>
<li><p><code>quantity &lt; 1 || quantity &gt; 10</code> prevents negative quantities and unreasonably large orders.</p>
</li>
<li><p><code>items.length &gt; 50</code> prevents oversized requests.</p>
</li>
<li><p><code>product.stock &lt; quantity</code> ensures the order doesn't exceed available inventory.</p>
</li>
<li><p>All totals, discounts, and final prices are calculated server-side from server-side prices.</p>
</li>
</ul>
<p><strong>Define the valid range for every numeric input. Recalculate every total server-side. The client isn't a trusted source for prices or quantities.</strong></p>
<h2 id="heading-6-jwt-misconfiguration">6. JWT Misconfiguration</h2>
<p>JWTs are everywhere in Node.js APIs, and the <code>jsonwebtoken</code> library makes them easy to use. That's both good and bad: they're easy to use correctly, but they're also easy to use in ways that look fine until they're not.</p>
<p>The OWASP Top 10 classifies authentication failures under <a href="https://owasp.org/Top10/A07_2021-Identification_and_Authentication_Failures/">Identification and Authentication Failures (A07:2021)</a>. Three misconfigurations show up repeatedly.</p>
<h3 id="heading-how-to-identify-this-vulnerability-in-your-code">How to Identify This Vulnerability in Your Code</h3>
<p><strong>Mistake 1 — Algorithm not specified in</strong> <code>verify()</code>:</p>
<pre><code class="language-javascript">// Vulnerable
const decoded = jwt.verify(token, process.env.JWT_SECRET);
</code></pre>
<p>Without an <code>algorithms</code> option, some JWT implementations can be tricked into accepting tokens that declare <code>"alg": "none"</code> in their header — meaning no signature is required at all.</p>
<p><strong>Mistake 2 — Weak or hardcoded secrets</strong>:</p>
<pre><code class="language-javascript">// Vulnerable
const token = jwt.sign({ userId: user.id }, 'secret');
</code></pre>
<p>A short or predictable HS256 secret can be brute-forced offline if an attacker obtains a valid token.</p>
<p><strong>Mistake 3 — Sensitive data in the payload</strong>:</p>
<pre><code class="language-javascript">// Vulnerable
const token = jwt.sign({
  userId: user.id,
  passwordHash: user.passwordHash, // never do this
  role: user.role
}, secret);
</code></pre>
<p>JWT payloads are base64-encoded, not encrypted. Anyone who holds the token can decode the payload and read its contents.</p>
<h3 id="heading-how-to-fix-these-issues">How to Fix These Issues</h3>
<pre><code class="language-javascript">// Secure JWT implementation
const jwt = require('jsonwebtoken');
const crypto = require('crypto');

// Generate a strong secret once and store it as an environment variable:
// node -e "console.log(crypto.randomBytes(64).toString('hex'))"
const JWT_SECRET = process.env.JWT_SECRET;

if (!JWT_SECRET || Buffer.from(JWT_SECRET, 'hex').length &lt; 32) {
  throw new Error('JWT_SECRET must be at least 32 random bytes');
}

function signToken(userId) {
  return jwt.sign(
    { sub: userId },         // 'sub' is the standard claim for the user identifier
    JWT_SECRET,
    {
      algorithm: 'HS256',    // always declare the algorithm explicitly
      expiresIn: '15m',      // short-lived tokens reduce the window if a token is stolen
      issuer: 'your-app-name',
      audience: 'your-app-name'
    }
  );
}

function verifyToken(token) {
  return jwt.verify(token, JWT_SECRET, {
    algorithms: ['HS256'],   // whitelist only the expected algorithm
    issuer: 'your-app-name',
    audience: 'your-app-name'
  });
}

function authenticate(req, res, next) {
  const authHeader = req.headers.authorization;

  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'No token provided' });
  }

  const token = authHeader.split(' ')[1];

  try {
    const decoded = verifyToken(token);
    req.user = { id: decoded.sub };
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}
</code></pre>
<p><strong>A note on token revocation</strong>: JWTs are stateless, meaning your server keeps no record of them. This means you can't immediately invalidate a token on logout or account compromise without extra infrastructure.</p>
<p>Common solutions include short-lived access tokens (15 minutes) paired with refresh tokens stored server-side, or a token denylist in a fast store like Redis. Choose the approach that matches your application's requirements.</p>
<p><strong>JWT security checklist</strong>:</p>
<ul>
<li><p>Always pass <code>algorithms: ['HS256']</code> to <code>verify()</code>.</p>
</li>
<li><p>Use a secret of at least 32 random bytes generated with <code>crypto.randomBytes</code>.</p>
</li>
<li><p>Set short expiration times.</p>
</li>
<li><p>Store only non-sensitive identifiers (user ID) in the payload — not emails, passwords, or roles.</p>
</li>
<li><p>Use standard claims: <code>sub</code>, <code>iss</code>, <code>aud</code>, <code>exp</code>.</p>
</li>
<li><p>Plan your revocation strategy before going to production.</p>
</li>
</ul>
<h2 id="heading-summary">Summary</h2>
<p>Here's a quick reference for the six vulnerability categories covered in this tutorial:</p>
<table>
<thead>
<tr>
<th>Vulnerability</th>
<th>Root Cause</th>
<th>Core Fix</th>
</tr>
</thead>
<tbody><tr>
<td>IDOR</td>
<td>Authorization check missing</td>
<td>Verify ownership from the authenticated session</td>
</tr>
<tr>
<td>Mass Assignment</td>
<td>All request body fields applied to database</td>
<td>Allowlist allowed fields explicitly</td>
</tr>
<tr>
<td>Prototype Pollution</td>
<td>Recursive merge of untrusted input</td>
<td>Store state server-side. Use <code>Object.create(null)</code> for merge targets.</td>
</tr>
<tr>
<td>Race Condition</td>
<td>Check-then-act without atomicity</td>
<td>Atomic <code>UPDATE ... WHERE condition</code> or transactions</td>
</tr>
<tr>
<td>Business Logic Flaw</td>
<td>Trusting client-supplied numeric values</td>
<td>Regex-validate inputs. Use integer arithmetic for money.</td>
</tr>
<tr>
<td>JWT Misconfiguration</td>
<td>No algorithm allowlist, weak secrets</td>
<td>Explicit algorithm array, strong random secret, short expiration</td>
</tr>
</tbody></table>
<p>None of these vulnerabilities requires a clever attacker. They just need code that trusts the wrong thing at the wrong time.</p>
<p>The patterns worth internalizing: always derive authorization from the session, never from the request. Validate every numeric input with a range. Make financial writes atomic. Build your JWT configuration explicitly.</p>
<p>Understanding how to fix these vulnerabilities is one side of the picture. Understanding how they're actually identified and exploited during a real security assessment is the other.</p>
<p>If you want to go deeper on the offensive side — how penetration testers approach web application targets, what they look for, and how they chain multiple flaws together — this <a href="https://hackita.it/articoli/attacchi-applicazioni-web/">guide to web application attack techniques</a> covers that perspective in detail.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Implement Role-Based Access Control in a Node.js REST API with JWT ]]>
                </title>
                <description>
                    <![CDATA[ The first time I built an API without thinking about roles, I gave every logged-in user the same access. It worked fine until a regular user accidentally hit a delete endpoint and wiped test data. Tha ]]>
                </description>
                <link>https://www.freecodecamp.org/news/role-based-access-control-nodejs-rest-api-jwt/</link>
                <guid isPermaLink="false">6a4fb4570140649a4367b476</guid>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ api ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Zia Ullah ]]>
                </dc:creator>
                <pubDate>Thu, 09 Jul 2026 14:46:47 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/d742efbd-8170-4fb6-8851-1f7c6ef9125e.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>The first time I built an API without thinking about roles, I gave every logged-in user the same access. It worked fine until a regular user accidentally hit a delete endpoint and wiped test data. That was the day I actually sat down and learned RBAC properly.</p>
<p>Role-Based Access Control sounds fancy, but the idea is simple: what you can do depends on <em>who you are</em>, not just <em>that you're logged in</em>. An admin deletes users. An editor creates posts. A regular user just reads. Same app, completely different experience depending on who's asking.</p>
<p>That's what we're building here. A REST API with three roles: JWT to carry those roles on every request, and a pair of middleware functions that check permissions before your route handlers even run. There's no database hit per request, and no if/else soup in your business logic.</p>
<p>By the end, you'll have three working roles (<code>admin</code>, <code>editor</code>, <code>user</code>) each locked to their own endpoints. More importantly, the pattern is transferable: once it clicks, you'll wire it into your next project without needing a tutorial.</p>
<p><strong>Full source code on GitHub:</strong> <a href="https://github.com/ziaongit/nodejs-rbac-jwt-api">github.com/ziaongit/nodejs-rbac-jwt-api</a></p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-youll-learn">What You'll Learn</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-well-build">What We'll Build</a></p>
</li>
<li><p><a href="#heading-project-setup">Project Setup</a></p>
</li>
<li><p><a href="#heading-setting-up-the-in-memory-data-store">Setting Up the In-Memory Data Store</a></p>
</li>
<li><p><a href="#heading-building-the-auth-routes">Building the Auth Routes</a></p>
</li>
<li><p><a href="#heading-building-the-rbac-middleware">Building the RBAC Middleware</a></p>
</li>
<li><p><a href="#heading-building-the-protected-routes">Building the Protected Routes</a></p>
</li>
<li><p><a href="#heading-putting-it-all-together">Putting It All Together</a></p>
</li>
<li><p><a href="#heading-testing-the-api">Testing the API</a></p>
</li>
<li><p><a href="#heading-key-takeaways">Key Takeaways</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-youll-learn">What You'll Learn</h2>
<ul>
<li><p>What RBAC is and how it differs from basic authentication</p>
</li>
<li><p>How to embed roles in JWT payloads</p>
</li>
<li><p>How to write reusable Express middleware for token verification and role checking</p>
</li>
<li><p>How to protect API routes based on user roles</p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ul>
<li><p>Node.js (v18+) installed</p>
</li>
<li><p>Basic knowledge of Express.js</p>
</li>
<li><p>Familiarity with how JWTs work (we'll cover the relevant parts)</p>
</li>
<li><p>npm installed</p>
</li>
</ul>
<h2 id="heading-what-well-build">What We'll Build</h2>
<p>We'll build a REST API for a simple content management system with three user roles:</p>
<table>
<thead>
<tr>
<th>Role</th>
<th>Permissions</th>
</tr>
</thead>
<tbody><tr>
<td><code>user</code></td>
<td>Read content</td>
</tr>
<tr>
<td><code>editor</code></td>
<td>Read + create content</td>
</tr>
<tr>
<td><code>admin</code></td>
<td>Full access — read, create, delete content, manage users</td>
</tr>
</tbody></table>
<p>The API will expose these endpoints:</p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Endpoint</th>
<th>Access</th>
</tr>
</thead>
<tbody><tr>
<td>POST</td>
<td>/api/auth/register</td>
<td>Public</td>
</tr>
<tr>
<td>POST</td>
<td>/api/auth/login</td>
<td>Public</td>
</tr>
<tr>
<td>GET</td>
<td>/api/content</td>
<td>user, editor, admin</td>
</tr>
<tr>
<td>POST</td>
<td>/api/content</td>
<td>editor, admin</td>
</tr>
<tr>
<td>DELETE</td>
<td>/api/content/:id</td>
<td>admin only</td>
</tr>
<tr>
<td>GET</td>
<td>/api/admin/users</td>
<td>admin only</td>
</tr>
</tbody></table>
<h2 id="heading-project-setup">Project Setup</h2>
<p>Create a new folder and initialize the project:</p>
<pre><code class="language-bash">mkdir nodejs-rbac-jwt-api
cd nodejs-rbac-jwt-api
npm init -y
</code></pre>
<p>Install the dependencies:</p>
<pre><code class="language-bash">npm install express jsonwebtoken bcryptjs dotenv
npm install --save-dev nodemon
</code></pre>
<p>Here's what each package does:</p>
<ul>
<li><p><strong>express</strong>: web framework for building the API</p>
</li>
<li><p><strong>jsonwebtoken</strong>: creates and verifies JWTs</p>
</li>
<li><p><strong>bcryptjs</strong>: securely hashes passwords</p>
</li>
<li><p><strong>dotenv</strong>: reads your <code>.env</code> file so you're not hardcoding secrets in your source code</p>
</li>
</ul>
<p>Update <code>package.json</code> to add start scripts:</p>
<pre><code class="language-json">"scripts": {
  "start": "node src/app.js",
  "dev": "nodemon src/app.js"
}
</code></pre>
<p>Create the project structure:</p>
<pre><code class="language-plaintext">nodejs-rbac-jwt-api/
├── src/
│   ├── middleware/
│   │   └── auth.js
│   ├── routes/
│   │   ├── auth.js
│   │   ├── content.js
│   │   └── admin.js
│   ├── data/
│   │   └── users.js
│   └── app.js
├── .env
├── .env.example
└── package.json
</code></pre>
<p>Create your <code>.env</code> file:</p>
<pre><code class="language-plaintext">JWT_SECRET=your_super_secret_key_change_this_in_production
PORT=3000
</code></pre>
<p><strong>Important:</strong> Never commit your <code>.env</code> file to version control. Add it to <code>.gitignore</code>.</p>
<h2 id="heading-setting-up-the-in-memory-data-store">Setting Up the In-Memory Data Store</h2>
<p>We don't have a database here, just an array in memory. The point was to keep the focus on RBAC, not spend half the tutorial on database config. In a real project, swap the array for whatever database you're already using.</p>
<p>Create <code>src/data/users.js</code>:</p>
<pre><code class="language-javascript">// In-memory users store
// In production, replace this with a real database (MongoDB, PostgreSQL, etc.)
const users = [];

const findUserByEmail = (email) =&gt; users.find((u) =&gt; u.email === email);
const findUserById = (id) =&gt; users.find((u) =&gt; u.id === id);
const createUser = (user) =&gt; {
  users.push(user);
  return user;
};
const getAllUsers = () =&gt; users.map(({ password, ...user }) =&gt; user);

module.exports = { findUserByEmail, findUserById, createUser, getAllUsers };
</code></pre>
<p>One thing worth noting: <code>getAllUsers</code> uses destructuring to drop the password before returning anything. Never send password fields in API responses, even hashed ones.</p>
<h2 id="heading-building-the-auth-routes">Building the Auth Routes</h2>
<p>The auth routes handle registration and login. Login is where roles first enter the picture — we embed the user's role directly into the JWT payload.</p>
<p>Create <code>src/routes/auth.js</code>:</p>
<pre><code class="language-javascript">const express = require('express');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const { findUserByEmail, createUser } = require('../data/users');

const router = express.Router();

// POST /api/auth/register
router.post('/register', async (req, res) =&gt; {
  const { name, email, password, role } = req.body;

  if (!name || !email || !password) {
    return res.status(400).json({ message: 'Name, email, and password are required' });
  }

  if (findUserByEmail(email)) {
    return res.status(409).json({ message: 'Email already registered' });
  }

  // Only allow valid roles — default to 'user' if none provided
  const validRoles = ['user', 'editor', 'admin'];
  const assignedRole = validRoles.includes(role) ? role : 'user';

  const hashedPassword = await bcrypt.hash(password, 10);

  const newUser = {
    id: Date.now().toString(),
    name,
    email,
    password: hashedPassword,
    role: assignedRole,
  };

  createUser(newUser);

  res.status(201).json({
    message: 'User registered successfully',
    user: {
      id: newUser.id,
      name: newUser.name,
      email: newUser.email,
      role: newUser.role,
    },
  });
});

// POST /api/auth/login
router.post('/login', async (req, res) =&gt; {
  const { email, password } = req.body;

  if (!email || !password) {
    return res.status(400).json({ message: 'Email and password are required' });
  }

  const user = findUserByEmail(email);
  if (!user) {
    return res.status(401).json({ message: 'Invalid credentials' });
  }

  const isMatch = await bcrypt.compare(password, user.password);
  if (!isMatch) {
    return res.status(401).json({ message: 'Invalid credentials' });
  }

  // Issue JWT — embed role in the payload
  const token = jwt.sign(
    {
      id: user.id,
      email: user.email,
      role: user.role,   // ← This is the key part for RBAC
    },
    process.env.JWT_SECRET,
    { expiresIn: '24h' }
  );

  res.json({
    message: 'Login successful',
    token,
  });
});

module.exports = router;
</code></pre>
<p>The most important line is the JWT payload:</p>
<pre><code class="language-javascript">jwt.sign({ id, email, role }, process.env.JWT_SECRET, { expiresIn: '24h' })
</code></pre>
<p>By embedding <code>role</code> in the token, every subsequent request carries the user's permissions without requiring a database lookup. The server just verifies the token and reads the role from the payload.</p>
<h2 id="heading-building-the-rbac-middleware">Building the RBAC Middleware</h2>
<p>This is the core of the system. We need two separate middleware functions:</p>
<ol>
<li><p><code>verifyToken</code> confirms the JWT is valid and attaches the decoded payload to <code>req.user</code></p>
</li>
<li><p><code>checkRole</code> confirms the user has the required role for a specific route</p>
</li>
</ol>
<p>Keeping them separate gives you flexibility. Some routes only need authentication. Others need both authentication and a specific role.</p>
<p>Create <code>src/middleware/auth.js</code>:</p>
<pre><code class="language-javascript">const jwt = require('jsonwebtoken');

// Middleware 1: Verify the JWT token
const verifyToken = (req, res, next) =&gt; {
  const authHeader = req.headers['authorization'];
  const token = authHeader &amp;&amp; authHeader.split(' ')[1]; // Expects: Bearer &lt;token&gt;

  if (!token) {
    return res.status(401).json({ message: 'Access denied. No token provided.' });
  }

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded; // Attach decoded payload (including role) to request
    next();
  } catch (err) {
    return res.status(403).json({ message: 'Invalid or expired token.' });
  }
};

// Middleware 2: Check if user has one of the required roles
const checkRole = (...allowedRoles) =&gt; {
  return (req, res, next) =&gt; {
    if (!req.user) {
      return res.status(401).json({ message: 'Not authenticated.' });
    }

    if (!allowedRoles.includes(req.user.role)) {
      return res.status(403).json({
        message: `Access denied. Required role: ${allowedRoles.join(' or ')}. Your role: ${req.user.role}`,
      });
    }

    next();
  };
};

module.exports = { verifyToken, checkRole };
</code></pre>
<p><code>checkRole</code> uses a rest parameter (<code>...allowedRoles</code>) so you can pass in one or multiple roles:</p>
<pre><code class="language-javascript">checkRole('admin')                  // only admin
checkRole('editor', 'admin')        // editor or admin
checkRole('user', 'editor', 'admin') // all roles
</code></pre>
<p>This makes route definitions clean and readable — the permissions are visible right at the route level.</p>
<h2 id="heading-building-the-protected-routes">Building the Protected Routes</h2>
<p>Now let's wire up routes that use the middleware.</p>
<p>Create <code>src/routes/content.js</code>:</p>
<pre><code class="language-javascript">const express = require('express');
const { verifyToken, checkRole } = require('../middleware/auth');

const router = express.Router();

// In-memory content store
const content = [
  { id: '1', title: 'Getting Started with Node.js', author: 'admin' },
  { id: '2', title: 'Express Middleware Explained', author: 'editor' },
];

// GET /api/content — all authenticated users
router.get('/', verifyToken, checkRole('user', 'editor', 'admin'), (req, res) =&gt; {
  res.json({ content });
});

// POST /api/content — editors and admins only
router.post('/', verifyToken, checkRole('editor', 'admin'), (req, res) =&gt; {
  const { title } = req.body;

  if (!title) {
    return res.status(400).json({ message: 'Title is required' });
  }

  const newItem = {
    id: Date.now().toString(),
    title,
    author: req.user.email,
  };

  content.push(newItem);
  res.status(201).json({ message: 'Content created', item: newItem });
});

// DELETE /api/content/:id — admin only
router.delete('/:id', verifyToken, checkRole('admin'), (req, res) =&gt; {
  const index = content.findIndex((c) =&gt; c.id === req.params.id);

  if (index === -1) {
    return res.status(404).json({ message: 'Content not found' });
  }

  content.splice(index, 1);
  res.json({ message: 'Content deleted successfully' });
});

module.exports = router;
</code></pre>
<p>Notice how readable each route is:</p>
<pre><code class="language-javascript">router.delete('/:id', verifyToken, checkRole('admin'), handler)
</code></pre>
<p>You can understand the access control without reading the handler body. This is one of the key advantages of middleware-based RBAC: permissions live at the routing layer, not buried in business logic.</p>
<p>Create <code>src/routes/admin.js</code>:</p>
<pre><code class="language-javascript">const express = require('express');
const { verifyToken, checkRole } = require('../middleware/auth');
const { getAllUsers } = require('../data/users');

const router = express.Router();

// GET /api/admin/users — admin only
router.get('/users', verifyToken, checkRole('admin'), (req, res) =&gt; {
  res.json({ users: getAllUsers() });
});

module.exports = router;
</code></pre>
<h2 id="heading-putting-it-all-together">Putting It All Together</h2>
<p>Create <code>src/app.js</code>:</p>
<pre><code class="language-javascript">require('dotenv').config();
const express = require('express');

const authRoutes = require('./routes/auth');
const contentRoutes = require('./routes/content');
const adminRoutes = require('./routes/admin');

const app = express();

app.use(express.json());

// Routes
app.use('/api/auth', authRoutes);
app.use('/api/content', contentRoutes);
app.use('/api/admin', adminRoutes);

// Health check
app.get('/', (req, res) =&gt; {
  res.json({ message: 'RBAC API is running' });
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () =&gt; {
  console.log(`Server running on port ${PORT}`);
});
</code></pre>
<h2 id="heading-testing-the-api">Testing the API</h2>
<p>Start the server:</p>
<pre><code class="language-bash">npm run dev
</code></pre>
<h3 id="heading-step-1-register-users-with-different-roles">Step 1: Register Users with Different Roles</h3>
<p>Register an admin:</p>
<pre><code class="language-bash">curl -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"name": "Admin User", "email": "admin@example.com", "password": "password123", "role": "admin"}'
</code></pre>
<p>Register an editor:</p>
<pre><code class="language-bash">curl -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"name": "Editor User", "email": "editor@example.com", "password": "password123", "role": "editor"}'
</code></pre>
<p>Register a regular user (no role specified — defaults to <code>user</code>):</p>
<pre><code class="language-bash">curl -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"name": "Regular User", "email": "user@example.com", "password": "password123"}'
</code></pre>
<h3 id="heading-step-2-log-in-and-get-a-token">Step 2: Log in and Get a Token</h3>
<pre><code class="language-bash">curl -X POST http://localhost:3000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "user@example.com", "password": "password123"}'
</code></pre>
<p>You'll get a response like:</p>
<pre><code class="language-json">{
  "message": "Login successful",
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
</code></pre>
<p>Copy the token.</p>
<h3 id="heading-step-3-test-role-based-access">Step 3: Test Role-based Access</h3>
<p><strong>Read content as a regular user (should succeed):</strong></p>
<pre><code class="language-bash">curl http://localhost:3000/api/content \
  -H "Authorization: Bearer YOUR_TOKEN_HERE"
</code></pre>
<p><strong>Try creating content as a regular user (should fail — 403):</strong></p>
<pre><code class="language-bash">curl -X POST http://localhost:3000/api/content \
  -H "Authorization: Bearer YOUR_TOKEN_HERE" \
  -H "Content-Type: application/json" \
  -d '{"title": "New Article"}'
</code></pre>
<p>Response:</p>
<pre><code class="language-json">{
  "message": "Access denied. Required role: editor or admin. Your role: user"
}
</code></pre>
<p>Now log in as an editor and try the same POST request. It succeeds. Log in as admin and try the DELETE route. Only the admin token will work.</p>
<h3 id="heading-step-4-decode-the-jwt-to-see-the-role">Step 4: Decode the JWT to See the Role</h3>
<p>You can paste any token into <a href="https://jwt.io">jwt.io</a> to inspect the payload. You'll see something like:</p>
<pre><code class="language-json">{
  "id": "1720300000000",
  "email": "admin@example.com",
  "role": "admin",
  "iat": 1720300000,
  "exp": 1720386400
}
</code></pre>
<p>The <code>role</code> field is exactly what <code>checkRole</code> reads on every protected request.</p>
<h2 id="heading-key-takeaways">Key Takeaways</h2>
<p>Roles live in the JWT payload. The role travels with the token — no extra DB call needed every time someone hits a protected route. It gets embedded at login and verified cryptographically on each request.</p>
<p>Middleware is composable. <code>verifyToken</code> and <code>checkRole</code> are separate, reusable functions. You can chain them on any route in any combination.</p>
<p>Permissions are visible at the route level. <code>router.delete('/:id', verifyToken, checkRole('admin'), handler)</code> tells you everything about access control before you even read the handler.</p>
<p><strong>Before you ship this to production:</strong></p>
<ul>
<li><p>The in-memory array was just to keep this tutorial focused — replace it with a real database before anything goes near production. A server restart wipes all your users right now.</p>
</li>
<li><p>That 24h token expiry is too long. Cut it to 15 minutes and add refresh token rotation. A stolen token becomes useless fast.</p>
</li>
<li><p>Re-validate roles from the DB on sensitive operations. A role change won't reflect in an existing token until it expires</p>
</li>
<li><p>HTTPS, always</p>
</li>
<li><p>If your permission logic grows beyond "check a role", look at <a href="https://casl.js.org/">casl</a>. It handles attribute-level rules cleanly</p>
</li>
</ul>
<h2 id="heading-conclusion">Conclusion</h2>
<p>The core of it fits in two middleware functions and a JWT payload. I've used this same pattern across several projects. And once you've built it yourself, you'll start spotting it everywhere, because almost every multi-user app needs some version of it.</p>
<p><strong>Full source code on GitHub:</strong> <a href="https://github.com/ziaongit/nodejs-rbac-jwt-api">github.com/ziaongit/nodejs-rbac-jwt-api</a></p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
