<?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" version="2.0"><channel><title><![CDATA[Azure Projects]]></title><description><![CDATA[This Blog is All About Azure Hand-on]]></description><link>https://azurepj.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6969ffdbe1b77aba344f9a56/b9d8e319-ee49-41b7-8230-4900a9b2d61d.jpg</url><title>Azure Projects</title><link>https://azurepj.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Tue, 15 Sep 2026 05:49:25 GMT</lastBuildDate><atom:link href="https://azurepj.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Breaking and Hardening Azure Workload Identities: From Long-Lived Secrets to GitHub OIDC Federation]]></title><description><![CDATA[Replacing a client secret with OIDC does not automatically give you least privilege.
It solves one important problem:

How does the workload authenticate?

It does not automatically solve another equa]]></description><link>https://azurepj.hashnode.dev/breaking-and-hardening-azure-workload-identities-from-long-lived-secrets-to-github-oidc-federation</link><guid isPermaLink="true">https://azurepj.hashnode.dev/breaking-and-hardening-azure-workload-identities-from-long-lived-secrets-to-github-oidc-federation</guid><category><![CDATA[Azure]]></category><category><![CDATA[cybersecurity]]></category><category><![CDATA[Terraform]]></category><category><![CDATA[GitHub Actions]]></category><dc:creator><![CDATA[Sesank Munukutla (Naga)]]></dc:creator><pubDate>Thu, 10 Sep 2026 12:12:09 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6969ffdbe1b77aba344f9a56/6082c4a8-0478-40ee-85ea-c4fc053a1e12.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Replacing a client secret with OIDC does <strong>not</strong> automatically give you least privilege.</p>
<p>It solves one important problem:</p>
<blockquote>
<p><strong>How does the workload authenticate?</strong></p>
</blockquote>
<p>It does not automatically solve another equally important problem:</p>
<blockquote>
<p><strong>What can that workload do after authentication succeeds?</strong></p>
</blockquote>
<p>That distinction became the central security question behind my <strong>AZ-01 Azure Workload Identity Security Lab</strong>.</p>
<p>I wanted to build something more substantial than a Terraform deployment that simply demonstrated GitHub OIDC. Instead, I designed the project as a complete security-engineering lifecycle:</p>
<pre><code class="language-text">Vulnerable workload identity
        ↓
Controlled credential compromise
        ↓
Authorization blast-radius testing
        ↓
Evidence capture
        ↓
Secretless identity redesign
        ↓
Azure RBAC reduction
        ↓
Post-remediation re-attack
        ↓
Explicit positive + negative validation
        ↓
Terraform teardown
        ↓
Cleanup verification
        ↓
CI/CD and repository hardening
</code></pre>
<p>The result was an evidence-driven Azure security lab using <strong>Microsoft Entra ID, Azure RBAC, Terraform, GitHub Actions, GitHub OIDC, Azure Storage, PowerShell, and Trivy</strong>.</p>
<p>The environment used only <strong>synthetic data and project-owned Azure resources</strong>. Nothing in this project should be interpreted as production penetration testing, universal least-privilege proof, or broad Azure security assurance. Every test was deliberately bounded.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6969ffdbe1b77aba344f9a56/79652d6d-ca9e-4716-b25f-9b29455227b4.png" alt="Figure 1 — AZ-01 Azure Workload Identity Attack &amp; Secretless Federation Lab." style="display:block;margin:0 auto" />

<h2>The security problem I wanted to model</h2>
<p>Workload identities are everywhere.</p>
<p>Applications, automation platforms, CI/CD pipelines, infrastructure tooling, and background services all need to authenticate to cloud APIs.</p>
<p>Historically, one common approach has been:</p>
<pre><code class="language-text">Application
    ↓
Client ID + client secret
    ↓
Microsoft Entra service principal
    ↓
Azure RBAC
    ↓
Azure resources
</code></pre>
<p>The architecture works.</p>
<p>The problem is the <strong>credential lifecycle</strong>.</p>
<p>A persistent client secret is replayable. If it is copied from a CI/CD system, exposed through logging, committed accidentally, leaked from configuration, or extracted from another trusted system, an attacker may be able to authenticate as that workload until the credential expires or is revoked.</p>
<p>But authentication is only half of the problem.</p>
<p>Suppose the compromised workload identity also has excessive authorization.</p>
<p>Now the attack becomes:</p>
<pre><code class="language-text">Credential compromise
        +
Excessive Azure RBAC
        =
Larger authorization blast radius
</code></pre>
<p>That was the condition I wanted AZ-01 to demonstrate.</p>
<h2>Building a deliberately vulnerable baseline</h2>
<p>The first meaningful version of the lab intentionally contained a vulnerable Microsoft Entra workload identity.</p>
<p>The identity had a temporary persistent credential and broader permissions than the workload ultimately required.</p>
<p>I also created two important boundaries:</p>
<pre><code class="language-text">Workload Resource Group
        |
        +-- workload resources
        +-- private synthetic storage
        +-- authorized test targets

Negative-Control Resource Group
        |
        +-- benign project-owned canary
        +-- NO workload role assignment
</code></pre>
<p>The negative control was critical.</p>
<p>Without one, successful access only proves what the compromised identity <strong>can</strong> reach.</p>
<p>A negative control also tests something it <strong>should not</strong> reach.</p>
<p>That distinction gave me a concrete authorization boundary rather than relying on assumptions.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6969ffdbe1b77aba344f9a56/1f375b4c-27c0-45ae-a746-3e52cb2c2965.png" alt="Figure 2 — AZ-01 lifecycle from credential risk through remediation, revalidation, teardown, and repository hardening." style="display:block;margin:0 auto" />

<h2>Phase 3: Controlled credential-compromise validation</h2>
<p>I did not want the attack phase to become arbitrary Azure enumeration.</p>
<p>The tests were restricted to known project-owned resources and synthetic data.</p>
<p>The Phase 3 test matrix was:</p>
<table>
<thead>
<tr>
<th>Test</th>
<th>Security question</th>
<th>Result</th>
</tr>
</thead>
<tbody><tr>
<td><strong>AT-01</strong></td>
<td>Can the intentionally exposed workload credential authenticate?</td>
<td>PASS</td>
</tr>
<tr>
<td><strong>AT-02</strong></td>
<td>Can the compromised identity enumerate the workload resource group?</td>
<td>PASS</td>
</tr>
<tr>
<td><strong>AT-03</strong></td>
<td>Can it perform a benign management-plane mutation?</td>
<td>PASS</td>
</tr>
<tr>
<td><strong>AT-04</strong></td>
<td>Can it access the known synthetic blob?</td>
<td>PASS</td>
</tr>
<tr>
<td><strong>AT-05</strong></td>
<td>Can it access the negative-control canary?</td>
<td>DENIED as expected</td>
</tr>
</tbody></table>
<p>The management-plane mutation was deliberately harmless and its original state was restored after testing.</p>
<h3>AT-01 — The copied credential worked</h3>
<p>The first test answered the most fundamental question:</p>
<blockquote>
<p>If an attacker possesses the intentionally exposed workload credential, can it actually authenticate?</p>
</blockquote>
<p>The answer was yes.</p>
<p>The Azure CLI session successfully authenticated as the vulnerable service principal.</p>
<p>That established the identity-compromise condition required for every subsequent test.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6969ffdbe1b77aba344f9a56/c36a2005-1a57-457e-8d77-b3233bc0266f.png" alt="Figure 3 — Successful authentication using the intentionally exposed lab workload credential. Sensitive identifiers and credential material are excluded from the public evidence." style="display:block;margin:0 auto" />

<p>This result alone did <strong>not</strong> prove an authorisation problem.</p>
<p>It only proved:</p>
<pre><code class="language-text">Authentication = compromised
</code></pre>
<p>The next tests investigated authorisation.</p>
<h3>AT-02 — Resource-group enumeration succeeded</h3>
<p>The compromised workload identity could enumerate resources inside the dedicated workload resource group.</p>
<p>That demonstrated management-plane visibility inside the assigned authorisation boundary.</p>
<p>Importantly, I did not expand the test into arbitrary subscription enumeration.</p>
<p>The objective was to measure this identity's permissions <strong>against known targets</strong>, not discover unrelated Azure infrastructure.</p>
<h3>AT-03 — A management-plane mutation succeeded</h3>
<p>Next, I wanted a stronger signal than read-only enumeration.</p>
<p>The identity attempted a benign tag modification against a project-owned workload resource.</p>
<p>The mutation succeeded.</p>
<p>After validating the behaviour, the original tag state was restored and verified.</p>
<p>This was significant because the blast radius was no longer theoretical.</p>
<p>The compromised workload credential could authenticate and exercise an authorisation path capable of modifying workload infrastructure.</p>
<h3>AT-04 — Synthetic data access succeeded</h3>
<p>The workload was also deliberately granted access to known synthetic Azure Storage data.</p>
<p>Using the compromised identity, access to that known blob succeeded.</p>
<p>Again, the point was not to collect data.</p>
<p>The point was to answer:</p>
<blockquote>
<p>Does credential possession expose the data-plane permissions already granted to this workload?</p>
</blockquote>
<p>Yes.</p>
<h3>AT-05 — The negative control was denied</h3>
<p>Then came one of the most valuable results in the lab.</p>
<p>The same compromised identity attempted to access a benign canary in the separate negative-control resource group.</p>
<p>The request was denied.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6969ffdbe1b77aba344f9a56/dfd6c5bf-7613-4a3c-946e-f7695f73ba84.png" alt="Figure 3 — Successful authentication using the intentionally exposed lab workload credential. Sensitive identifiers and credential material are excluded from the public evidence." style="display:block;margin:0 auto" />

<p>This prevented an important overstatement.</p>
<p>The correct interpretation was <strong>not</strong>:</p>
<blockquote>
<p>“The credential compromise gave access to Azure.”</p>
</blockquote>
<p>It was:</p>
<blockquote>
<p>“The credential compromise exposed the permissions already assigned to that workload identity within the tested authorization boundary.”</p>
</blockquote>
<p>That is a much more defensible security conclusion.</p>
<h2>What the vulnerable tests actually demonstrated</h2>
<p>The Phase 3 attack taught an important lesson:</p>
<h3>Authentication compromise and authorisation blast radius are separate controls</h3>
<p>A stolen client secret answers:</p>
<pre><code class="language-text">Who can authenticate as this workload?
</code></pre>
<p>Azure RBAC answers:</p>
<pre><code class="language-text">What can the authenticated workload do?
</code></pre>
<p>You can therefore have several combinations:</p>
<pre><code class="language-text">Strong authentication + excessive RBAC = still dangerous

Weak authentication + narrow RBAC = compromised identity,
                                     but reduced blast radius

Strong authentication + narrow RBAC = preferred design
</code></pre>
<p>This meant my remediation needed <strong>two separate changes</strong>.</p>
<p>I had to:</p>
<ol>
<li><p>eliminate the persistent workload credential; and</p>
</li>
<li><p>reduce the workload's Azure authorization.</p>
</li>
</ol>
<p>Fixing only one would have left the architecture incomplete.</p>
<h2>Remediation: moving to GitHub OIDC federation</h2>
<p>The vulnerable Phase 2/3 Azure environment was destroyed after its evidence was captured.</p>
<p>For the remediation tests, I created a <strong>fresh validation deployment</strong>.</p>
<p>This detail matters.</p>
<p>AZ-01 does <strong>not</strong> claim that I mutated the original identity in place and proved the old Phase 3 secret could no longer authenticate.</p>
<p>The preserved Phase 3 deployment and the later Phase 4/5 deployment represent different validation windows.</p>
<p>The remediated authentication architecture became:</p>
<pre><code class="language-text">GitHub Actions
      ↓
GitHub-issued OIDC token
      ↓
Microsoft Entra federated identity credential
      ↓
Workload service principal
      ↓
Azure RBAC
      ↓
Authorized Azure resource
</code></pre>
<p>There is no persistent Azure workload client secret in this path.</p>
<h2>How the federation trust was constrained</h2>
<p>The Microsoft Entra application trusts GitHub through a federated identity credential.</p>
<p>Conceptually, the Terraform looks like this:</p>
<pre><code class="language-hcl">resource "azuread_application_federated_identity_credential" "github_main" {
  application_id = azuread_application.workload.id

  display_name = "az01-github-main-oidc"

  audiences = [
    "api://AzureADTokenExchange"
  ]

  issuer = "https://token.actions.githubusercontent.com"

  subject = "&lt;reviewed repository identity&gt;:ref:refs/heads/main"
}
</code></pre>
<p>The important security property is the <strong>subject restriction</strong>.</p>
<p>The trust is not intended to cover arbitrary repositories, pull requests, branches, tags, or an entire GitHub organization.</p>
<p>The reviewed federation boundary is tied to the intended repository identity and <code>main</code> branch.</p>
<p>Publishing the trust tuple does not provide a credential. Authentication still requires a valid GitHub-issued OIDC token that satisfies the configured trust conditions.</p>
<h2>Authentication hardening was not enough</h2>
<p>OIDC removed the persistent workload-secret problem.</p>
<p>But I still needed to address authorization.</p>
<p>The workload did not need broad management-plane control.</p>
<p>Its legitimate function was much narrower:</p>
<blockquote>
<p>Read the intended synthetic data from one known Azure Storage container.</p>
</blockquote>
<p>So the remediated RBAC assignment became container-scoped:</p>
<pre><code class="language-hcl">resource "azurerm_role_assignment" "workload_synthetic_data_reader" {
  scope                = azurerm_storage_container.synthetic_data.id
  role_definition_name = "Storage Blob Data Reader"
  principal_id         = azuread_service_principal.workload.object_id
}
</code></pre>
<p>The workload had no corresponding Contributor assignment, management-plane Reader assignment, storage-write role, storage-owner role, or negative-control resource-group assignment in the remediated Terraform.</p>
<p>This was the key architectural transition:</p>
<table>
<thead>
<tr>
<th>Control</th>
<th>Vulnerable state</th>
<th>Remediated state</th>
</tr>
</thead>
<tbody><tr>
<td>Authentication</td>
<td>Persistent client secret</td>
<td>GitHub OIDC federation</td>
</tr>
<tr>
<td>Credential model</td>
<td>Replayable workload credential</td>
<td>Short-lived federated authentication</td>
</tr>
<tr>
<td>Authorization</td>
<td>Excessive workload permissions</td>
<td>Container-scoped Blob Data Reader</td>
</tr>
<tr>
<td>Management plane</td>
<td>Excessive actions available</td>
<td>Tested actions denied</td>
</tr>
<tr>
<td>Storage write</td>
<td>Excess existed in attack model</td>
<td>Dedicated write probe denied</td>
</tr>
<tr>
<td>Negative control</td>
<td>No role</td>
<td>No role</td>
</tr>
</tbody></table>
<p>Now I needed to prove the new behavior at runtime.</p>
<h2>Phase 4: Proving the OIDC positive path</h2>
<p>A configuration that <em>looks</em> correct is not sufficient evidence.</p>
<p>I created a manual GitHub Actions validation workflow and used it to test the new identity.</p>
<p>The workflow successfully:</p>
<pre><code class="language-text">GitHub Actions
      ↓
Obtained GitHub OIDC token
      ↓
Exchanged token with Microsoft Entra
      ↓
Authenticated Azure CLI
      ↓
Accessed metadata for exact known synthetic blob
      ↓
Cleaned up Azure CLI session
</code></pre>
<p>The workflow deliberately validated <strong>metadata access</strong>, rather than printing the blob contents.</p>
<p>The Phase 4 run verified OIDC authentication, the intended metadata-only synthetic-blob access, and CLI cleanup.</p>
<p>It did not claim that management-plane or write operations were denied. Those checks were reserved for Phase 5.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6969ffdbe1b77aba344f9a56/7ffaf2cd-7cb1-4a3a-bd86-e3281ad4f40d.png" alt="Figure 5 — Successful GitHub OIDC authentication and intended synthetic-blob metadata access." style="display:block;margin:0 auto" />

<p>At this point I had established:</p>
<pre><code class="language-text">Secretless authentication: PASS
Intended read path:         PASS
</code></pre>
<p>But I still had not proved least-privilege behavior.</p>
<p>That required re-attacking the remediated identity.</p>
<h2>Phase 5: Re-attacking the remediated workload identity</h2>
<p>This was arguably the most important phase of AZ-01.</p>
<p>Many security projects stop here:</p>
<pre><code class="language-text">Terraform changed
        ↓
OIDC enabled
        ↓
Project complete
</code></pre>
<p>I wanted the opposite.</p>
<p>The remediation needed to survive controlled authorization tests.</p>
<p>The final post-remediation matrix was:</p>
<table>
<thead>
<tr>
<th>Test</th>
<th>Tested behavior</th>
<th>Result</th>
</tr>
</thead>
<tbody><tr>
<td><strong>RT-01</strong></td>
<td>GitHub OIDC authentication + intended Azure context</td>
<td>PASS</td>
</tr>
<tr>
<td><strong>RT-02</strong></td>
<td>Workload resource-group enumeration</td>
<td>Explicitly denied</td>
</tr>
<tr>
<td><strong>RT-03</strong></td>
<td>Benign management-plane tag mutation</td>
<td>Explicitly denied</td>
</tr>
<tr>
<td><strong>RT-04R</strong></td>
<td>Known synthetic blob metadata/read path</td>
<td>PASS</td>
</tr>
<tr>
<td><strong>RT-04W</strong></td>
<td>Dedicated synthetic blob creation/write</td>
<td>Explicitly denied</td>
</tr>
<tr>
<td><strong>RT-05</strong></td>
<td>Negative-control canary access</td>
<td>Explicitly denied</td>
</tr>
<tr>
<td><strong>RT-06</strong></td>
<td>Account-level container listing</td>
<td>Explicitly denied</td>
</tr>
<tr>
<td><strong>Cleanup</strong></td>
<td>Workload CLI session/profile cleanup</td>
<td>PASS</td>
</tr>
</tbody></table>
<p>These are <strong>bounded runtime observations</strong>, not universal Azure permission claims.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6969ffdbe1b77aba344f9a56/cc022eb7-0456-4594-b963-59d9e720dfcb.png" alt="Figure 6 — Post-remediation non-mutating validation showing intended access and tested authorization boundaries." style="display:block;margin:0 auto" />

<h2>Positive controls mattered</h2>
<p>One subtle design decision was to keep a positive control inside the authorization tests.</p>
<p>Suppose a command fails.</p>
<p>Without a positive control, several explanations are possible:</p>
<pre><code class="language-text">Expected RBAC denial
Network failure
Authentication failure
Wrong resource
Broken workflow
Wrong Azure context
CLI failure
</code></pre>
<p>Those are not equivalent.</p>
<p>So the tests repeatedly confirmed that:</p>
<pre><code class="language-text">OIDC authentication works
        +
Known permitted access works
        +
Specific excessive operation receives authorization denial
</code></pre>
<p>That gives much stronger evidence that the negative result came from the intended authorization boundary rather than a broken test environment.</p>
<h2>Explicit denial classification</h2>
<p>I also avoided treating every failure as a security success.</p>
<p>A generic failure or ambiguous HTTP response was not automatically classified as PASS.</p>
<p>For denial tests, the workflow expected explicit Azure authorization-denial behavior.</p>
<p>If an operation unexpectedly succeeded, the test was a failure.</p>
<p>If the result was ambiguous, it was not enough to claim enforcement.</p>
<p>This distinction prevents one of the easiest mistakes in security testing:</p>
<blockquote>
<p>Interpreting “command failed” as “security control worked.”</p>
</blockquote>
<h3>RT-02 — Resource enumeration denied</h3>
<p>The remediated workload attempted the resource-list operation against the known workload resource group.</p>
<p>It received an explicit authorization denial.</p>
<p>The same capability that had succeeded during the vulnerable baseline was no longer available to the tested identity.</p>
<h3>RT-03 — Management-plane mutation denied</h3>
<p>The benign tag-mutation probe was then executed separately.</p>
<p>It received an explicit authorization denial.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6969ffdbe1b77aba344f9a56/36aaa8a3-74e5-43c4-9c6a-c863ec5dd7f1.png" alt="Figure 7 — The remediated workload identity was explicitly denied the tested management-plane mutation." style="display:block;margin:0 auto" />

<p>The claim here is intentionally narrow:</p>
<blockquote>
<p>The tested tag mutation against the known workload resource group was denied.</p>
</blockquote>
<p>Not:</p>
<blockquote>
<p>The identity can never perform any Azure management-plane action anywhere.</p>
</blockquote>
<h3>RT-04R — Required access still worked</h3>
<p>Least privilege should not mean “nothing works.”</p>
<p>The exact known synthetic blob metadata path still succeeded through Microsoft Entra authentication.</p>
<p>That proved the remediation preserved the workload's legitimate function.</p>
<pre><code class="language-text">Required read access → PASS
</code></pre>
<h3>RT-04W — Storage write denied</h3>
<p>The next test attempted to create a dedicated synthetic write-probe blob.</p>
<p>It received an explicit authorization denial.</p>
<p>The original baseline synthetic blob was never overwritten.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6969ffdbe1b77aba344f9a56/ef6302aa-22f4-48f7-a552-ac2d5d54c831.png" alt="Figure 8 — Dedicated synthetic storage-write probe explicitly denied." style="display:block;margin:0 auto" />

<p>Again, this does not prove every possible storage write API would fail.</p>
<p>It proves the exact tested write action was denied against the known target.</p>
<h3>RT-05 — Negative control still denied</h3>
<p>The project-owned canary remained inaccessible.</p>
<p>This gave me a useful comparison across both security states:</p>
<pre><code class="language-text">Vulnerable deployment:
Compromised identity → negative control DENIED

Remediated deployment:
Federated identity → negative control DENIED
</code></pre>
<p>The negative control continued to establish a known boundary outside the workload's intended authorization scope.</p>
<h3>RT-06 — Account-level container listing denied</h3>
<p>Finally, account-level container enumeration against the known workload storage account received an explicit authorization denial.</p>
<p>Meanwhile, the narrower known synthetic blob metadata path continued to work.</p>
<p>That contrast illustrates the outcome I wanted:</p>
<pre><code class="language-text">Known required operation
        → allowed

Broader tested operation
        → denied
</code></pre>
<h2>Why I ran mutation tests separately</h2>
<p>RT-03 and RT-04W were opt-in mutation probes.</p>
<p>They were never enabled together.</p>
<p>That was deliberate.</p>
<p>Running mutation tests one at a time reduces uncertainty if something unexpected succeeds and makes cleanup responsibility much clearer.</p>
<p>The Phase 5 evidence therefore consolidates multiple dispatches rather than pretending all tests occurred in one giant workflow run.</p>
<p>That may sound like a small procedural detail, but it reflects an important security-testing principle:</p>
<blockquote>
<p>Reduce the blast radius of the test harness itself.</p>
</blockquote>
<h2>Phase 6: Hardening the repository and CI/CD path</h2>
<p>Cloud runtime security was only part of the project.</p>
<p>Once the validated Terraform became the project baseline, I wanted the repository itself to resist accidental regression.</p>
<p>I added a static security CI pipeline with three required checks:</p>
<table>
<thead>
<tr>
<th>CI job</th>
<th>Purpose</th>
</tr>
</thead>
<tbody><tr>
<td><code>terraform-static-validation</code></td>
<td>Terraform formatting, initialization without backend, and validation</td>
</tr>
<tr>
<td><code>iac-security-scan</code></td>
<td>Trivy Terraform misconfiguration scanning</td>
</tr>
<tr>
<td><code>secret-scan</code></td>
<td>Trivy current-content secret scanning</td>
</tr>
</tbody></table>
<p>The Terraform path executes:</p>
<pre><code class="language-text">terraform fmt -check -recursive
terraform init -backend=false
terraform validate
</code></pre>
<p>There is intentionally no:</p>
<pre><code class="language-text">terraform plan
terraform apply
terraform destroy
Azure login
OIDC token request
</code></pre>
<p>The static CI pipeline does not need to recreate the Azure environment.</p>
<p>Its GitHub Actions permission is restricted to:</p>
<pre><code class="language-yaml">permissions:
  contents: read
</code></pre>
<p>Checkout does not persist credentials, external actions are pinned to reviewed commit SHAs, and scanner reports remain in runner-temporary storage rather than being uploaded with potentially sensitive context.</p>
<h2>A scanner finding I chose not to hide</h2>
<p>One of the more useful engineering moments happened when the IaC scan identified <code>AZU-0012</code> around storage public-network reachability.</p>
<p>It would have been easy to suppress the finding just to make CI green.</p>
<p>Instead, I treated it as a design decision.</p>
<p>The lab needed the synthetic storage endpoint reachable during its bounded validation window from owner-controlled systems and GitHub-hosted runners.</p>
<p>Compensating controls included:</p>
<pre><code class="language-text">Shared-key authentication disabled
OAuth-based authentication
TLS 1.2 minimum
Private container
Synthetic data only
Destroyed validation environment
</code></pre>
<p>Rather than making an unrelated infrastructure change purely to satisfy the scanner, I documented a <strong>narrow, time-bounded exception</strong> expiring on <strong>December 31, 2026</strong>.</p>
<p>All other unexcepted HIGH/CRITICAL findings remain fail-closed.</p>
<p>That is closer to real security engineering than pretending every scanner finding can be solved by blindly changing configuration.</p>
<h2>Then I deliberately broke CI</h2>
<p>A green pipeline proves the success path.</p>
<p>It does not prove the pipeline will reject anything.</p>
<p>So I introduced <strong>CF-01</strong>, a controlled CI failure.</p>
<p>The test created a valid but deliberately misformatted Terraform fixture <strong>only under the GitHub runner's temporary directory</strong>.</p>
<p>The sequence was:</p>
<pre><code class="language-text">Normal Terraform checks → PASS

Create temporary misformatted fixture
        ↓
terraform fmt -check
        ↓
REJECTED as expected
        ↓
terraform fmt
        ↓
Remediated
        ↓
terraform fmt -check
        ↓
PASS
        ↓
Delete temporary fixture
        ↓
Verify cleanup
        ↓
Intentionally fail controlled run
</code></pre>
<p>The controlled workflow failed exactly as intended.</p>
<p>Then the temporary failure-injection logic was removed, the normal workflow was restored, and all three production CI jobs passed again.</p>
<p>This gave me something more meaningful than:</p>
<blockquote>
<p>“My CI pipeline is green.”</p>
</blockquote>
<p>I could instead say:</p>
<blockquote>
<p>“I deliberately exercised one known negative path, observed the gate reject it, remediated the condition, and revalidated the production pipeline.”</p>
</blockquote>
<h2>Protecting the validated baseline</h2>
<p>The final repository-hardening phase added or verified:</p>
<ul>
<li><p>pull requests required for <code>main</code>;</p>
</li>
<li><p>conversation resolution;</p>
</li>
<li><p>all three security CI jobs required;</p>
</li>
<li><p>required branches kept current with the protected baseline;</p>
</li>
<li><p>force-push protection;</p>
</li>
<li><p>branch-deletion protection;</p>
</li>
<li><p>read-only default GitHub Actions permissions;</p>
</li>
<li><p>GitHub Actions PR approval disabled;</p>
</li>
<li><p>historical Phase 4/5 Azure OIDC workflows disabled;</p>
</li>
<li><p>stale runtime repository variables removed;</p>
</li>
<li><p>weekly Dependabot visibility retained;</p>
</li>
<li><p>major dependency/provider changes kept behind explicit compatibility review.</p>
</li>
</ul>
<p>A particularly important choice was to <strong>disable</strong> historical runtime workflows rather than delete or rewrite them.</p>
<p>Their source remains available as engineering provenance.</p>
<p>Their execution path is retired.</p>
<p>That preserves history without leaving unnecessary Azure authentication automation active.</p>
<h2>Phase 7: Destroying the environment was part of the project</h2>
<p>I do not consider an ephemeral security lab complete because <code>terraform destroy</code> returned success.</p>
<p>The cleanup itself needed validation.</p>
<p>After Phase 5 evidence was reviewed, the later secretless validation environment was destroyed.</p>
<p>The teardown matrix was:</p>
<table>
<thead>
<tr>
<th>Check</th>
<th>Result</th>
</tr>
</thead>
<tbody><tr>
<td>Owner context verified</td>
<td>PASS</td>
</tr>
<tr>
<td>Terraform state empty</td>
<td>PASS</td>
</tr>
<tr>
<td>Workload resource group absent</td>
<td>PASS</td>
</tr>
<tr>
<td>Negative-control resource group absent</td>
<td>PASS</td>
</tr>
<tr>
<td>Workload Entra application absent</td>
<td>PASS</td>
</tr>
<tr>
<td>Workload service principal absent</td>
<td>PASS</td>
</tr>
<tr>
<td>Phase 5 repository secrets absent</td>
<td>PASS</td>
</tr>
</tbody></table>
<img src="https://cdn.hashnode.com/uploads/covers/6969ffdbe1b77aba344f9a56/4ea65c5d-0de9-434b-89b0-c70b277bb687.png" alt="Figure 9 — Terraform destroy completed for the later secretless validation deployment." style="display:block;margin:0 auto" />

<p>The checks were limited to exact known project-owned resources.</p>
<p>They do not claim universal subscription cleanup.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6969ffdbe1b77aba344f9a56/48571b68-be39-4b93-9140-520bb34c9571.png" alt="Figure 10 — Bounded post-destroy verification confirming the known AZ-01 resources and workload identity objects were absent." style="display:block;margin:0 auto" />

<p>The final cloud state is therefore simple:</p>
<pre><code class="language-text">Live AZ-01 Azure validation environment: NONE

Persistent workload client secret:       NOT RECREATED

Historical runtime OIDC workflows:        DISABLED

Protected static CI baseline:             ACTIVE
</code></pre>
<h2>Evidence hygiene mattered throughout</h2>
<p>Security labs have an uncomfortable property:</p>
<p>The most interesting output is often exactly what should <strong>not</strong> be published.</p>
<p>So the public repository intentionally excludes credential values, tokens, Terraform state and plan files, Azure CLI caches, raw authentication logs, and sensitive runtime identifiers.</p>
<p>Screenshots were captured and sanitized before publication.</p>
<p>Raw GitHub Actions authentication logs were also intentionally not copied into the repository because expanded workflow context can contain values that are unnecessary for demonstrating the result.</p>
<p>The goal was:</p>
<pre><code class="language-text">Enough evidence to support the claim

but

Not enough sensitive runtime material to create a new problem
</code></pre>
<h2>What AZ-01 taught me</h2>
<p>The biggest lesson was not simply:</p>
<blockquote>
<p>“GitHub OIDC is better than client secrets.”</p>
</blockquote>
<p>That is true, but incomplete.</p>
<p>The deeper lesson was:</p>
<blockquote>
<p><strong>Authentication architecture and authorization architecture must be engineered independently and then tested together.</strong></p>
</blockquote>
<p>OIDC reduced the credential-lifecycle risk.</p>
<p>Container-scoped Azure RBAC reduced the authorization blast radius.</p>
<p>Runtime testing verified the tested behavior.</p>
<p>Negative controls helped prevent exaggerated conclusions.</p>
<p>Positive controls prevented broken test environments from masquerading as successful security enforcement.</p>
<p>Controlled mutation testing demonstrated real authorization behavior without turning the lab into destructive testing.</p>
<p>Static CI protected the resulting source baseline.</p>
<p>Controlled CI failure validated one enforcement path.</p>
<p>Teardown verification completed the cloud-resource lifecycle.</p>
<p>And evidence hygiene made the results publishable without preserving sensitive runtime material.</p>
<h2>What this project does not prove</h2>
<p>Being precise about the limitations is just as important as documenting the successful tests.</p>
<p>AZ-01 does <strong>not</strong> claim:</p>
<pre><code class="language-text">Universal Azure least privilege
Universal authorization denial
Production readiness
Old-secret replay failure
In-place Phase 3 credential revocation
Arbitrary storage-read denial
Universal storage-write denial
Subscription-wide cleanup
Clean full Git history
Penetration-testing coverage
</code></pre>
<p>Phase 3 and the secretless Phase 4/5 environment were separate deployment windows.</p>
<p>The tested denials apply to the exact actions and known project-owned targets exercised by the lab.</p>
<p>Blob metadata access does not imply unrestricted content access.</p>
<p>Current-content secret scanning does not establish that every historical Git revision has always been clean.</p>
<p>Static Terraform and IaC checks do not replace Azure runtime authorization testing.</p>
<p>Those limitations do not weaken the project.</p>
<p>They make the claims defensible.</p>
<h2>Final architecture</h2>
<p>The project began with:</p>
<pre><code class="language-text">Long-lived workload credential
        ↓
Microsoft Entra service principal
        ↓
Excessive bounded Azure RBAC
        ↓
Credential compromise
        ↓
Authorized excessive operations
</code></pre>
<p>It finished with:</p>
<pre><code class="language-text">GitHub Actions
        ↓
Short-lived GitHub OIDC token
        ↓
Microsoft Entra workload identity federation
        ↓
Workload service principal
        ↓
Container-scoped Storage Blob Data Reader
        ↓
Required access succeeds
        +
Tested excessive actions explicitly denied
</code></pre>
<p>And around that cloud architecture now sits a repository control plane:</p>
<pre><code class="language-text">Pull Request
     ↓
Terraform validation
     +
IaC scanning
     +
Secret scanning
     ↓
Required checks
     ↓
Protected main
</code></pre>
<p>That complete lifecycle is what made AZ-01 valuable to me.</p>
<p>It was not just an OIDC implementation.</p>
<p>It was an exercise in <strong>attack-path reasoning, identity architecture, authorization design, security validation, evidence engineering, DevSecOps controls, and teardown discipline</strong>.</p>
<h2>Final project state</h2>
<p>AZ-01 is now closed as a synthetic, evidence-driven Azure workload-identity security lab.</p>
<p>The vulnerable baseline is preserved as historical evidence.</p>
<p>The later OIDC architecture was validated.</p>
<p>The tested authorization boundary was re-attacked.</p>
<p>The Azure validation environment was destroyed.</p>
<p>The long-lived workload client secret was not recreated.</p>
<p>Historical Azure runtime workflows are disabled.</p>
<p>The validated Terraform baseline is protected by repository-static security controls.</p>
<p>You can review the complete implementation, evidence, Terraform, architecture decisions, and validation records here:</p>
<p><strong>GitHub:</strong> <a href="https://github.com/nagasesank/AZ-01-azure-workload-identity-security-lab">nagasesank/AZ-01-azure-workload-identity-security-lab</a></p>
<p>If you are designing CI/CD identities in Azure, the main takeaway I would leave you with is this:</p>
<blockquote>
<p><strong>Removing secrets improves authentication security. Reducing permissions improves authorization security. Testing both is what turns the design into evidence.</strong></p>
</blockquote>
<hr />
<h2>Technology stack</h2>
<p><code>Microsoft Azure</code> · <code>Microsoft Entra ID</code> · <code>Azure RBAC</code> · <code>Terraform</code> · <code>GitHub Actions</code> · <code>GitHub OIDC</code> · <code>Azure Storage</code> · <code>PowerShell</code> · <code>Trivy</code></p>
<hr />
<h2>About this project</h2>
<p>AZ-01 is a controlled cloud-security engineering lab built with synthetic data and project-owned resources only. The repository preserves the implementation and evidence, while the Azure validation environment itself has been destroyed after testing.</p>
]]></content:encoded></item></channel></rss>